diff --git a/docs/adr/0030-process-lock-exclusion.md b/docs/adr/0030-process-lock-exclusion.md index 5aac07b2df..0e1458f936 100644 --- a/docs/adr/0030-process-lock-exclusion.md +++ b/docs/adr/0030-process-lock-exclusion.md @@ -40,6 +40,32 @@ guard cannot constrain legacy code after its final check. The support boundary t requires deployment control; host-kit does not claim to detect or evict every legacy user. Daemon registration has a separate cutover boundary: keep the same `daemon.lock` path and -refuse legacy files rather than automatically reclaiming them. Its startup tests must cover -an already-running older daemon and concurrent old/new startup. This does not expand the -host-kit mixed-protocol support contract. +refuse legacy files rather than automatically reclaiming them. A legacy daemon creates an +exclusive file; a hardened daemon creates a directory at that same path. The old acquisition +cannot unlink a directory, and the new acquisition retains an existing file. + +The [registration tests](../../src/__tests__/daemon-registration-owner.test.ts) exercise real +children using the c237027737 legacy acquisition and the current owner. They cover an older +daemon already running, a hardened owner waiting to publish metadata, and concurrent startup. +The client refuses an older registration before signaling or changing it. This proves the daemon +cutover; it does not expand the host-kit mixed-protocol support contract. Older clients still +require the deployment controls above. + +## Registration operations + +[Shared retirement](../../src/daemon-registration-owner.ts) owns verified termination, protected +metadata inspection and removal, and release. Takeover, failed startup, replay cleanup, timeout +reset and manual stop await its result. Abandoned recovery uses the same protected retirement +sequence without signaling a live process. Daemon publication and shutdown use functions bound +to their acquired claim. + +```mermaid +flowchart LR + C[Client lifecycle and timeout] --> R[Shared retirement] + M[Manual stop] --> R + P[Abandoned recovery and pruning] --> R + R --> L[Acquired registration claim] + D[Daemon startup and shutdown] --> O[Functions bound to own claim] + O --> L + L --> F[Protected metadata and reports] +``` diff --git a/packages/capture-kit/src/capture-admission/__tests__/audio-probe-session-resource.test.ts b/packages/capture-kit/src/capture-admission/__tests__/audio-probe-session-resource.test.ts index 073c33419b..c0e4d38565 100644 --- a/packages/capture-kit/src/capture-admission/__tests__/audio-probe-session-resource.test.ts +++ b/packages/capture-kit/src/capture-admission/__tests__/audio-probe-session-resource.test.ts @@ -1,3 +1,4 @@ +import { makeCaptureSessionBinding } from '../../durable-capture/session-binding.fixtures.ts'; import fs from 'node:fs/promises'; import path from 'node:path'; import { expect, test, vi } from 'vitest'; @@ -44,6 +45,10 @@ test('audio-probe disposes on a failed finish because terminating the helper is ); const session: DurableCaptureSessionState = {}; sessionStore.set(sessionName, session); + const binding = makeCaptureSessionBinding(sessionStore, sessionName, { + read: (session) => session.audioProbe, + replace: (session, audioProbe) => ({ ...session, audioProbe }), + }); const statusPath = path.join(sessionStore.resolveSessionDir(sessionName), 'audio-probe.json'); const terminate = vi.fn(async () => {}); let resolveExit!: (result: HostCommandResult) => void; @@ -85,9 +90,7 @@ test('audio-probe disposes on a failed finish because terminating the helper is }); await adoptStartedAudioProbe({ admissionLedger: createAudioProbeAdmissionLedger(), - session, - sessionName, - sessionStore, + binding, device, owner: localRuntimeOwner('apple'), fence, @@ -101,9 +104,7 @@ test('audio-probe disposes on a failed finish because terminating the helper is await expect( finishLiveAudioProbe({ intent: 'capture', - session: sessionStore.get(sessionName) ?? session, - sessionName, - sessionStore, + binding, }), ).rejects.toThrow('helper exited before completing the capture'); diff --git a/packages/capture-kit/src/capture-admission/__tests__/durable-capture-resource.fixtures.ts b/packages/capture-kit/src/capture-admission/__tests__/durable-capture-resource.fixtures.ts index 0cef989b60..291cd0d543 100644 --- a/packages/capture-kit/src/capture-admission/__tests__/durable-capture-resource.fixtures.ts +++ b/packages/capture-kit/src/capture-admission/__tests__/durable-capture-resource.fixtures.ts @@ -1,3 +1,4 @@ +import { makeCaptureSessionBinding } from '../../durable-capture/session-binding.fixtures.ts'; import { vi } from 'vitest'; import type { AppLogCompletion, AppLogLiveHandle } from '@agent-device/contracts/app-log-runtime'; import type { CleanupOutcome, FinishOutcome } from '@agent-device/contracts/durable-resource'; @@ -30,19 +31,10 @@ export const testCaptureStore = createDurableCaptureResourceStore({ export function createTestCaptureResource( store: DurableCaptureResourceStore<'app-log'> = testCaptureStore, ) { - return createDurableCaptureResource< - 'app-log', - AppLogLiveHandle, - AppLogCompletion, - TestCaptureSession - >({ + return createDurableCaptureResource<'app-log', AppLogLiveHandle, AppLogCompletion>({ resourceKind: 'app-log', displayName: 'test capture', store, - sessionSlot: { - read: (session) => session.appLog, - replace: (session, appLog) => ({ ...session, appLog, appLogFailure: undefined }), - }, completionMetadata: (completion) => ({ outputPath: completion.outputPath, completedAt: completion.completedAt, @@ -72,6 +64,10 @@ export function makeDurableCaptureContext( const session: TestCaptureSession = {}; sessionStore.set(sessionName, session); return { + binding: makeCaptureSessionBinding(sessionStore, sessionName, { + read: (session) => session.appLog, + replace: (session, appLog) => ({ ...session, appLog, appLogFailure: undefined }), + }), admissionLedger: createDurableCaptureAdmissionLedger({ displayName: 'test capture' }), session, sessionName, diff --git a/packages/capture-kit/src/capture-admission/__tests__/durable-capture-resource.test.ts b/packages/capture-kit/src/capture-admission/__tests__/durable-capture-resource.test.ts index a77610fac5..6a1e6e7a15 100644 --- a/packages/capture-kit/src/capture-admission/__tests__/durable-capture-resource.test.ts +++ b/packages/capture-kit/src/capture-admission/__tests__/durable-capture-resource.test.ts @@ -25,9 +25,7 @@ test('one coordinator exposes the typed manifest and all lifecycle entrypoints', await expect( testCaptureResource.finishLive({ intent: 'capture', - session: active, - sessionName: context.sessionName, - sessionStore: context.sessionStore, + binding: context.binding, }), ).resolves.toMatchObject({ outputPath: '/tmp/app.log', completedAt: 2 }); expect(context.sessionStore.get(context.sessionName)?.appLog).toBeUndefined(); diff --git a/packages/capture-kit/src/capture-admission/__tests__/screen-recording-boundary-faults.test.ts b/packages/capture-kit/src/capture-admission/__tests__/screen-recording-boundary-faults.test.ts index 775f5a3fae..e13d95baff 100644 --- a/packages/capture-kit/src/capture-admission/__tests__/screen-recording-boundary-faults.test.ts +++ b/packages/capture-kit/src/capture-admission/__tests__/screen-recording-boundary-faults.test.ts @@ -1,3 +1,4 @@ +import { makeCaptureSessionBinding } from '../../durable-capture/session-binding.fixtures.ts'; import path from 'node:path'; import { expect, test, vi } from 'vitest'; import { createDurableResourceEnvelope } from '../../durable-resource-envelope.ts'; @@ -187,16 +188,11 @@ function createScreenRecordingTestResource( return createDurableCaptureResource< 'screen-recording', ScreenRecordingLiveHandle, - ScreenRecordingCompletion, - DurableCaptureSessionState + ScreenRecordingCompletion >({ resourceKind: 'screen-recording', displayName: 'screen recording', store, - sessionSlot: { - read: (session) => session.screenRecording, - replace: (session, screenRecording) => ({ ...session, screenRecording }), - }, completionMetadata: (completion) => ({ backend: completion.backend, outputPath: completion.outPath, @@ -217,9 +213,14 @@ function makeContext(resource: ReturnType session.screenRecording, + replace: (session, screenRecording) => ({ ...session, screenRecording }), + }); return { admissionLedger: createDurableCaptureAdmissionLedger({ displayName: 'screen recording' }), session, + binding, sessionName, sessionStore, device, diff --git a/packages/capture-kit/src/capture-admission/__tests__/screen-recording-session-resource.test.ts b/packages/capture-kit/src/capture-admission/__tests__/screen-recording-session-resource.test.ts index b048db0b3c..99e3b36278 100644 --- a/packages/capture-kit/src/capture-admission/__tests__/screen-recording-session-resource.test.ts +++ b/packages/capture-kit/src/capture-admission/__tests__/screen-recording-session-resource.test.ts @@ -1,3 +1,4 @@ +import { makeCaptureSessionBinding } from '../../durable-capture/session-binding.fixtures.ts'; import { expect, test, vi } from 'vitest'; import { PendingTransferGuard } from '@agent-device/contracts/async-lifecycle'; import { localRuntimeOwner } from '@agent-device/contracts/platform-runtime'; @@ -35,6 +36,10 @@ test('screen recording persists durable truth before adopting only handle and en const sessionName = 'recording'; const session: TestRecordingSession = { name: sessionName, device }; sessionStore.set(sessionName, session); + const binding = makeCaptureSessionBinding(sessionStore, sessionName, { + read: (session) => session.screenRecording, + replace: (session, screenRecording) => ({ ...session, screenRecording }), + }); const owner = localRuntimeOwner('android'); const fence = { token: 'recording-fence', generation: 1 } as const; const finish = vi.fn(async () => ({ @@ -79,9 +84,7 @@ test('screen recording persists durable truth before adopting only handle and en await adoptStartedScreenRecording({ admissionLedger: createScreenRecordingAdmissionLedger(), - session, - sessionName, - sessionStore, + binding, device: session.device, owner, fence, @@ -101,9 +104,10 @@ test('screen recording persists durable truth before adopting only handle and en const active = sessionStore.get(sessionName); if (!active) throw new Error('Expected screen-recording session'); - await expect( - finishLiveScreenRecording({ intent: 'capture', session: active, sessionName, sessionStore }), - ).resolves.toMatchObject({ backend: 'android', outPath: '/tmp/recording.mp4' }); + await expect(finishLiveScreenRecording({ intent: 'capture', binding })).resolves.toMatchObject({ + backend: 'android', + outPath: '/tmp/recording.mp4', + }); expect(finish).toHaveBeenCalledOnce(); expect(sessionStore.get(sessionName)?.screenRecording).toBeUndefined(); }); @@ -115,6 +119,10 @@ test('a failed recording finish keeps the record open and never disposes the rec const sessionName = 'recording'; const session: TestRecordingSession = { name: sessionName, device }; sessionStore.set(sessionName, session); + const binding = makeCaptureSessionBinding(sessionStore, sessionName, { + read: (session) => session.screenRecording, + replace: (session, screenRecording) => ({ ...session, screenRecording }), + }); const owner = localRuntimeOwner('android'); const fence = { token: 'recording-fence', generation: 1 } as const; const finishError = new Error('failed to retrieve playable Android recording'); @@ -150,9 +158,7 @@ test('a failed recording finish keeps the record open and never disposes the rec }); await adoptStartedScreenRecording({ admissionLedger: createScreenRecordingAdmissionLedger(), - session, - sessionName, - sessionStore, + binding, device: session.device, owner, fence, @@ -163,9 +169,7 @@ test('a failed recording finish keeps the record open and never disposes the rec const active = sessionStore.get(sessionName); if (!active) throw new Error('Expected screen-recording session'); - await expect( - finishLiveScreenRecording({ intent: 'capture', session: active, sessionName, sessionStore }), - ).rejects.toBe(finishError); + await expect(finishLiveScreenRecording({ intent: 'capture', binding })).rejects.toBe(finishError); expect(forceCleanup).not.toHaveBeenCalled(); expect(sessionStore.get(sessionName)?.screenRecording?.handle).toBe(handle); @@ -182,6 +186,10 @@ test('a record stop that fails after collecting resumes through the fence withou const sessionName = 'recording'; const session: TestRecordingSession = { name: sessionName, device }; sessionStore.set(sessionName, session); + const binding = makeCaptureSessionBinding(sessionStore, sessionName, { + read: (session) => session.screenRecording, + replace: (session, screenRecording) => ({ ...session, screenRecording }), + }); const owner = localRuntimeOwner('android'); const fence = { token: 'recording-fence', generation: 1 } as const; const signals = vi.fn(async () => ({ observation: { recorder: 'confirmed' as const } })); @@ -216,9 +224,7 @@ test('a record stop that fails after collecting resumes through the fence withou ); await adoptStartedScreenRecording({ admissionLedger: createScreenRecordingAdmissionLedger(), - session, - sessionName, - sessionStore, + binding, device: session.device, owner, fence, @@ -239,9 +245,7 @@ test('a record stop that fails after collecting resumes through the fence withou if (!active) throw new Error('Expected screen-recording session'); return finishLiveScreenRecording({ intent: 'capture', - session: active, - sessionName, - sessionStore, + binding, }); }; diff --git a/packages/capture-kit/src/capture-admission/__tests__/session-store.fixtures.ts b/packages/capture-kit/src/capture-admission/__tests__/session-store.fixtures.ts index dee8763861..02cc494180 100644 --- a/packages/capture-kit/src/capture-admission/__tests__/session-store.fixtures.ts +++ b/packages/capture-kit/src/capture-admission/__tests__/session-store.fixtures.ts @@ -1,28 +1,16 @@ import path from 'node:path'; import { safeSessionName } from '@agent-device/host-kit/session-paths'; -import type { DurableCaptureSessionStore } from '../../durable-capture/index.ts'; import { mkdtempForTestSync } from '../../tmp-dir.fixtures.ts'; +import { makeCaptureFixtureStore } from '../../durable-capture/session-binding.fixtures.ts'; -export type CaptureAdmissionSessionStore = DurableCaptureSessionStore & - Readonly<{ - get(name: string): S | undefined; - sessionsDir: string; - }>; +export type CaptureAdmissionSessionStore = ReturnType< + typeof makeCaptureAdmissionSessionStore +>; -/** - * The whole of the daemon `SessionStore` these admission modules ever address — `set`, - * `resolveSessionDir`, and the read-back a test asserts on — over a fresh temp directory, so a - * test of this family needs no session record, store class, or daemon import. - */ -export function makeCaptureAdmissionSessionStore( - prefix: string, -): CaptureAdmissionSessionStore { +export function makeCaptureAdmissionSessionStore(prefix: string) { const sessionsDir = mkdtempForTestSync(prefix); - const sessions = new Map(); - return { - set: (name, session) => void sessions.set(name, session), - get: (name) => sessions.get(name), - resolveSessionDir: (name) => path.join(sessionsDir, safeSessionName(name)), + return Object.freeze({ + ...makeCaptureFixtureStore((name) => path.join(sessionsDir, safeSessionName(name))), sessionsDir, - }; + }); } diff --git a/packages/capture-kit/src/capture-admission/audio-probe-session-resource.ts b/packages/capture-kit/src/capture-admission/audio-probe-session-resource.ts index 83863d3336..21f2b91f1a 100644 --- a/packages/capture-kit/src/capture-admission/audio-probe-session-resource.ts +++ b/packages/capture-kit/src/capture-admission/audio-probe-session-resource.ts @@ -9,26 +9,20 @@ import type { RuntimeOwnerRef, } from '@agent-device/contracts/platform-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import type { DurableCaptureSessionStore } from '../durable-capture/index.ts'; +import type { DurableCaptureSessionBinding } from '../durable-capture/index.ts'; import { createDurableCaptureResource } from './durable-capture-resource.ts'; import type { DurableCaptureFinishIntent } from './durable-capture-resource.ts'; import type { AudioProbeAdmissionLedger } from './audio-probe-admission-ledger.ts'; import { audioProbeResourceStore } from './audio-probe-resource-store.ts'; -import type { DurableCaptureSessionState } from './session-state-slice.ts'; export const audioProbeDurableResource = createDurableCaptureResource< 'audio-probe', AudioProbeLiveHandle, - AudioProbeCompletion, - DurableCaptureSessionState + AudioProbeCompletion >({ resourceKind: 'audio-probe', displayName: 'audio probe', store: audioProbeResourceStore, - sessionSlot: { - read: (session) => session.audioProbe, - replace: (session, audioProbe) => ({ ...session, audioProbe }), - }, completionMetadata: (completion) => ({ backend: completion.backend ?? 'unknown', source: completion.source, @@ -48,9 +42,7 @@ export const audioProbeDurableResource = createDurableCaptureResource< export function adoptStartedAudioProbe(params: { admissionLedger: AudioProbeAdmissionLedger; - session: DurableCaptureSessionState; - sessionName: string; - sessionStore: DurableCaptureSessionStore; + binding: DurableCaptureSessionBinding<'audio-probe', AudioProbeLiveHandle>; device: DeviceInfo; owner: RuntimeOwnerRef; fence: ResourceOwnershipFence; @@ -62,9 +54,7 @@ export function adoptStartedAudioProbe(params: { } export function finishLiveAudioProbe(params: { - session: DurableCaptureSessionState; - sessionName: string; - sessionStore: DurableCaptureSessionStore; + binding: DurableCaptureSessionBinding<'audio-probe', AudioProbeLiveHandle>; intent: DurableCaptureFinishIntent; }): Promise { return audioProbeDurableResource.finishLive(params); diff --git a/packages/capture-kit/src/capture-admission/durable-capture-resource.ts b/packages/capture-kit/src/capture-admission/durable-capture-resource.ts index a03751be14..8a9800e69c 100644 --- a/packages/capture-kit/src/capture-admission/durable-capture-resource.ts +++ b/packages/capture-kit/src/capture-admission/durable-capture-resource.ts @@ -9,8 +9,8 @@ import { type AdoptStartedDurableCaptureParams, type DurableCaptureFinishIntent, type DurableCaptureRecoveryParams, - type DurableCaptureResourceDefinition, - type DurableCaptureSessionStore, + type DurableCaptureRecordDefinition, + type DurableCaptureSessionBinding, type FinishRecoveredDurableCaptureParams, } from '../durable-capture/index.ts'; import type { LiveResourceHandle } from '@agent-device/contracts/durable-resource'; @@ -23,8 +23,8 @@ import type { DurableSessionResourceKind } from './durable-session-resource-kind export type { DurableCaptureFinishIntent, DurableSessionResourceKind }; -type AdoptStartedSessionCaptureParams = Omit< - AdoptStartedDurableCaptureParams, +type AdoptStartedSessionCaptureParams = Omit< + AdoptStartedDurableCaptureParams, 'reportUndurableCleanup' > & Readonly<{ admissionLedger: DurableCaptureAdmissionLedger }>; @@ -34,21 +34,14 @@ type SessionCaptureRecoveryParams; -/** - * Where the shared durable-capture mechanics meet the two authorities that stay with the session - * owner: the admission ledger, which decides whether a failed adoption blocks a replacement start, - * and the session store, whose naming rule turns a session id into the one directory its records - * may occupy. The session record itself stays opaque behind `S`; only the definition's own - * `sessionSlot` looks inside it. - */ +/** The session binding owns its slot and path; the ledger owns failed-adoption admission. */ export function createDurableCaptureResource< K extends DurableSessionResourceKind, H extends LiveResourceHandle, C, - S, ->(definition: DurableCaptureResourceDefinition) { +>(definition: DurableCaptureRecordDefinition) { const sessionResourcePath = ( - sessionStore: DurableCaptureSessionStore, + sessionStore: Readonly<{ resolveSessionDir(name: string): string }>, sessionName: string, ): string => definition.store.resolvePath(sessionStore.resolveSessionDir(sessionName)); const recoveryParams = ( @@ -70,7 +63,7 @@ export function createDurableCaptureResource< }): ResourceOwnershipFence { return createNextDurableCaptureFence(definition, params); }, - adoptStarted(params: AdoptStartedSessionCaptureParams): Promise { + adoptStarted(params: AdoptStartedSessionCaptureParams): Promise { return adoptStartedDurableCapture( definition, { @@ -80,31 +73,27 @@ export function createDurableCaptureResource< else params.admissionLedger.blockUndurableCleanup(device, outcome.reason); }, }, - sessionResourcePath(params.sessionStore, params.sessionName), + definition.store.resolvePath(params.binding.sessionDir), ); }, finishLive(params: { - session: S; - sessionName: string; - sessionStore: DurableCaptureSessionStore; + binding: DurableCaptureSessionBinding; intent: DurableCaptureFinishIntent; }): Promise { return finishLiveDurableCapture( definition, params, - sessionResourcePath(params.sessionStore, params.sessionName), + definition.store.resolvePath(params.binding.sessionDir), ); }, finishRecovered(params: FinishRecoveredDurableCaptureParams): Promise { return finishRecoveredDurableCapture(definition, params); }, - forceCleanupLive(params: { - session: S; - sessionName?: string; - sessionStore?: DurableCaptureSessionStore; - resourcePath: string; - }): Promise { - return forceCleanupLiveDurableCapture(definition, params); + forceCleanupLive(params: { binding: DurableCaptureSessionBinding }): Promise { + return forceCleanupLiveDurableCapture(definition, { + ...params, + resourcePath: definition.store.resolvePath(params.binding.sessionDir), + }); }, recoverAll(params: SessionCaptureRecoveryParams) { return recoverDurableCaptureResourcesAfterDaemonLock(recoveryParams(params)); diff --git a/packages/capture-kit/src/capture-admission/perf-capture-session-resource.ts b/packages/capture-kit/src/capture-admission/perf-capture-session-resource.ts index 6e6e14104b..c6dbee2515 100644 --- a/packages/capture-kit/src/capture-admission/perf-capture-session-resource.ts +++ b/packages/capture-kit/src/capture-admission/perf-capture-session-resource.ts @@ -9,26 +9,20 @@ import type { RuntimeOwnerRef, } from '@agent-device/contracts/platform-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import type { DurableCaptureSessionStore } from '../durable-capture/index.ts'; +import type { DurableCaptureSessionBinding } from '../durable-capture/index.ts'; import { createDurableCaptureResource } from './durable-capture-resource.ts'; import type { DurableCaptureFinishIntent } from './durable-capture-resource.ts'; import type { PerfCaptureAdmissionLedger } from './perf-capture-admission-ledger.ts'; import { perfCaptureResourceStore } from './perf-capture-resource-store.ts'; -import type { DurableCaptureSessionState } from './session-state-slice.ts'; export const perfCaptureDurableResource = createDurableCaptureResource< 'perf-capture', PerfNativeCaptureLiveHandle, - PerfNativeCaptureCompletion, - DurableCaptureSessionState + PerfNativeCaptureCompletion >({ resourceKind: 'perf-capture', displayName: 'perf capture', store: perfCaptureResourceStore, - sessionSlot: { - read: (session) => session.perfCapture, - replace: (session, perfCapture) => ({ ...session, perfCapture }), - }, completionMetadata: (completion) => ({ kind: typeof completion.kind === 'string' ? completion.kind : 'unknown', mode: typeof completion.mode === 'string' ? completion.mode : 'unknown', @@ -46,9 +40,7 @@ export const perfCaptureDurableResource = createDurableCaptureResource< export function adoptStartedPerfCapture(params: { admissionLedger: PerfCaptureAdmissionLedger; - session: DurableCaptureSessionState; - sessionName: string; - sessionStore: DurableCaptureSessionStore; + binding: DurableCaptureSessionBinding<'perf-capture', PerfNativeCaptureLiveHandle>; device: DeviceInfo; owner: RuntimeOwnerRef; fence: ResourceOwnershipFence; @@ -60,9 +52,7 @@ export function adoptStartedPerfCapture(params: { } export function finishLivePerfCapture(params: { - session: DurableCaptureSessionState; - sessionName: string; - sessionStore: DurableCaptureSessionStore; + binding: DurableCaptureSessionBinding<'perf-capture', PerfNativeCaptureLiveHandle>; intent: DurableCaptureFinishIntent; }): Promise { return perfCaptureDurableResource.finishLive(params); diff --git a/packages/capture-kit/src/capture-admission/screen-recording-session-resource.ts b/packages/capture-kit/src/capture-admission/screen-recording-session-resource.ts index 66b54f4bcc..9a477dfbaf 100644 --- a/packages/capture-kit/src/capture-admission/screen-recording-session-resource.ts +++ b/packages/capture-kit/src/capture-admission/screen-recording-session-resource.ts @@ -16,27 +16,21 @@ import type { StopObservation } from '@agent-device/contracts/recording-stop-obs import type { DeviceInfo } from '@agent-device/kernel/device'; import type { DurableCaptureRecoveryControl, - DurableCaptureSessionStore, + DurableCaptureSessionBinding, } from '../durable-capture/index.ts'; import { createDurableCaptureResource } from './durable-capture-resource.ts'; import type { DurableCaptureFinishIntent } from './durable-capture-resource.ts'; import type { ScreenRecordingAdmissionLedger } from './screen-recording-admission-ledger.ts'; import { screenRecordingResourceStore } from './screen-recording-resource-store.ts'; -import type { DurableCaptureSessionState } from './session-state-slice.ts'; export const screenRecordingDurableResource = createDurableCaptureResource< 'screen-recording', ScreenRecordingLiveHandle, - ScreenRecordingCompletion, - DurableCaptureSessionState + ScreenRecordingCompletion >({ resourceKind: 'screen-recording', displayName: 'screen recording', store: screenRecordingResourceStore, - sessionSlot: { - read: (session) => session.screenRecording, - replace: (session, screenRecording) => ({ ...session, screenRecording }), - }, completionMetadata: encodeScreenRecordingCompletionMetadata, // ADR 0024 rule 6: the next stop re-collects the native artifact a failed export left behind, and // forced cleanup would delete exactly that. Disposal belongs to teardown and start rollback. @@ -50,9 +44,7 @@ export const screenRecordingDurableResource = createDurableCaptureResource< export function adoptStartedScreenRecording(params: { admissionLedger: ScreenRecordingAdmissionLedger; - session: DurableCaptureSessionState; - sessionName: string; - sessionStore: DurableCaptureSessionStore; + binding: DurableCaptureSessionBinding<'screen-recording', ScreenRecordingLiveHandle>; device: DeviceInfo; owner: RuntimeOwnerRef; fence: ResourceOwnershipFence; @@ -64,9 +56,7 @@ export function adoptStartedScreenRecording(params: { } export function finishLiveScreenRecording(params: { - session: DurableCaptureSessionState; - sessionName: string; - sessionStore: DurableCaptureSessionStore; + binding: DurableCaptureSessionBinding<'screen-recording', ScreenRecordingLiveHandle>; intent: DurableCaptureFinishIntent; }): Promise { return screenRecordingDurableResource.finishLive(params); diff --git a/packages/capture-kit/src/capture-admission/screen-recording-stop-recovery.ts b/packages/capture-kit/src/capture-admission/screen-recording-stop-recovery.ts index b59ee12d04..585a292243 100644 --- a/packages/capture-kit/src/capture-admission/screen-recording-stop-recovery.ts +++ b/packages/capture-kit/src/capture-admission/screen-recording-stop-recovery.ts @@ -12,15 +12,11 @@ import { deviceIdentity, sameDeviceIdentity, type DeviceInfo } from '@agent-devi import { AppError } from '@agent-device/kernel/errors'; import { isRecord } from '@agent-device/kernel/record'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; -import type { - DurableCaptureResourceRecord, - DurableCaptureSessionStore, -} from '../durable-capture/index.ts'; +import type { DurableCaptureResourceRecord } from '../durable-capture/index.ts'; import { SCREEN_RECORDING_COMPLETION_METADATA_KEY, screenRecordingDurableResource, } from './screen-recording-session-resource.ts'; -import type { DurableCaptureSessionState } from './session-state-slice.ts'; /** * What a `record stop` owes a session, decided from the durable recording manifest alone. @@ -45,7 +41,7 @@ const OPTIONAL_RESPONSE_FIELDS = [ type ScreenRecordingManifestParams = Readonly<{ sessionName: string; - sessionStore: DurableCaptureSessionStore; + sessionStore: Readonly<{ resolveSessionDir(name: string): string }>; }>; export function resolveScreenRecordingStopRecovery( diff --git a/packages/capture-kit/src/durable-capture/adoption.test.ts b/packages/capture-kit/src/durable-capture/adoption.test.ts index ac2cbff303..f3fb4a521d 100644 --- a/packages/capture-kit/src/durable-capture/adoption.test.ts +++ b/packages/capture-kit/src/durable-capture/adoption.test.ts @@ -68,3 +68,25 @@ test('a failed terminal transition preserves the primary error and reports it un reason: expect.stringMatching(/./), }); }); + +test('adoption refuses a retired lifetime even when its address has a vacant successor', async () => { + const context = makeDurableCaptureContext(); + const start = makeDurableCaptureStartResult(context); + context.sessionStore.retire(context.sessionStore.lookup(context.sessionName)); + const successor = { name: 'successor' }; + context.sessionStore.set(context.sessionName, successor); + await expect( + adoptStartedDurableCapture( + testCaptureDefinition, + { + ...context, + ...start, + throwIfCanceled: () => {}, + }, + context.resourcePath, + ), + ).rejects.toMatchObject({ code: 'COMMAND_FAILED' }); + expect(start.forceCleanup).toHaveBeenCalledOnce(); + expect(context.sessionStore.get(context.sessionName)).toBe(successor); + expect(testCaptureStore.read(context.resourcePath).status).toBe('missing'); +}); diff --git a/packages/capture-kit/src/durable-capture/adoption.ts b/packages/capture-kit/src/durable-capture/adoption.ts index 2390539458..fef4f1661a 100644 --- a/packages/capture-kit/src/durable-capture/adoption.ts +++ b/packages/capture-kit/src/durable-capture/adoption.ts @@ -15,7 +15,6 @@ import { import type { AdoptStartedDurableCaptureParams, DurableCaptureRecordDefinition, - DurableCaptureResourceDefinition, } from './definition.ts'; import { capitalizeDurableCaptureLabel, durableCaptureDiagnosticPrefix } from './labels.ts'; @@ -28,46 +27,43 @@ export async function adoptStartedDurableCapture< K extends string, H extends LiveResourceHandle, C, - S, >( - definition: DurableCaptureResourceDefinition, - params: AdoptStartedDurableCaptureParams, + definition: DurableCaptureRecordDefinition, + params: AdoptStartedDurableCaptureParams, resourcePath: string, ): Promise { let state: AdoptionState = { kind: 'pending' }; try { + params.binding.assertAdoptable(); const envelope = withPhase(validateStartedEnvelope(definition, params), 'active'); definition.store.write(resourcePath, envelope); state = { kind: 'persisted' }; params.throwIfCanceled(); const handle = params.pendingHandle.transfer(); state = { kind: 'transferred', handle }; - params.sessionStore.set( - params.sessionName, - definition.sessionSlot.replace(params.session, { handle, envelope }), - ); + params.binding.adopt({ handle, envelope }); } catch (error) { await recoverFailedAdoption(definition, params, resourcePath, state, error); throw error; } } -async function recoverFailedAdoption, C, S>( - definition: DurableCaptureResourceDefinition, - params: AdoptStartedDurableCaptureParams, +async function recoverFailedAdoption, C>( + definition: DurableCaptureRecordDefinition, + params: AdoptStartedDurableCaptureParams, resourcePath: string, state: AdoptionState, primaryError: unknown, ): Promise { + const mayPersist = params.binding.canPersist(); const persisted = - state.kind === 'pending' ? persistRecoveryTombstone(definition, params, resourcePath) : true; + state.kind === 'pending' + ? mayPersist && persistRecoveryTombstone(definition, params, resourcePath) + : true; const initialCleanupError = await disposeFailedAdoption(params, state); - const transition = confirmFailedAdoptionTransition( - definition, - params, - resourcePath, - initialCleanupError, - ); + const transition = !params.binding.canPersist() + ? { confirmed: false, cleanupError: initialCleanupError } + : confirmFailedAdoptionTransition(definition, params, resourcePath, initialCleanupError); params.reportUndurableCleanup( params.device, (!persisted && transition.cleanupError === undefined) || transition.confirmed @@ -84,9 +80,9 @@ async function recoverFailedAdoption, C, S>( +function confirmFailedAdoptionTransition, C>( definition: DurableCaptureRecordDefinition, - params: Pick, 'sessionName' | 'fence'>, + params: Pick, 'binding' | 'fence'>, resourcePath: string, cleanupError: unknown | undefined, ): { confirmed: boolean; cleanupError: unknown | undefined } { @@ -100,7 +96,7 @@ function confirmFailedAdoptionTransition( - params: AdoptStartedDurableCaptureParams, +async function disposeFailedAdoption( + params: AdoptStartedDurableCaptureParams, state: AdoptionState, ): Promise { try { @@ -122,16 +118,13 @@ async function disposeFailedAdoption, C, S>( +function persistRecoveryTombstone, C>( definition: DurableCaptureRecordDefinition, - params: AdoptStartedDurableCaptureParams, + params: AdoptStartedDurableCaptureParams, resourcePath: string, ): boolean { try { - definition.store.write( - resourcePath, - createExpectedEnvelope(definition, params, params.envelope.descriptor), - ); + definition.store.write(resourcePath, createRecoveryEnvelope(definition, params)); return true; } catch (descriptorError) { try { @@ -148,7 +141,7 @@ function persistRecoveryTombstone, C, S>( +function createRecoveryEnvelope, C>( definition: DurableCaptureRecordDefinition, - params: Pick< - AdoptStartedDurableCaptureParams, - 'sessionName' | 'device' | 'owner' | 'fence' - >, + params: AdoptStartedDurableCaptureParams, +): DurableResourceEnvelope { + try { + return withPhase(validateStartedEnvelope(definition, params), 'active'); + } catch { + return createExpectedEnvelope(definition, params, params.envelope.descriptor); + } +} + +function createExpectedEnvelope, C>( + definition: DurableCaptureRecordDefinition, + params: Pick, 'binding' | 'device' | 'owner' | 'fence'>, descriptor: DurableResourceEnvelope['descriptor'], ): DurableResourceEnvelope { return createDurableResourceEnvelope({ resourceKind: definition.resourceKind, - sessionId: params.sessionName, + sessionId: params.binding.address, device: deviceIdentity(params.device), owner: params.owner, fence: params.fence, @@ -180,11 +181,11 @@ function createExpectedEnvelope, C, S>( +function validateStartedEnvelope, C>( definition: DurableCaptureRecordDefinition, params: Pick< - AdoptStartedDurableCaptureParams, - 'sessionName' | 'device' | 'owner' | 'fence' | 'envelope' + AdoptStartedDurableCaptureParams, + 'binding' | 'device' | 'owner' | 'fence' | 'envelope' >, ): DurableResourceEnvelope { const decoded = decodeDurableResourceEnvelope(params.envelope); @@ -207,7 +208,7 @@ function validateStartedEnvelope( return true; } -function emitCleanupDiagnostic( +function emitCleanupDiagnostic( definition: DurableCaptureRecordDefinition, - params: Pick, 'sessionName'>, + params: Pick, 'binding'>, primaryError: unknown, cleanupError: unknown, ): void { @@ -260,7 +261,7 @@ function emitCleanupDiagnostic = Readonly<{ - set(name: string, session: S): void; - resolveSessionDir(name: string): string; -}>; - export type DurableCaptureSessionResource = Readonly<{ handle: H; envelope: DurableResourceEnvelope; }>; -export type DurableCaptureSessionSlot = Readonly<{ - read(session: S): DurableCaptureSessionResource | undefined; - replace(session: S, resource: DurableCaptureSessionResource | undefined): S; +export type DurableCaptureSessionBinding = Readonly<{ + address: string; + sessionDir: string; + read(): DurableCaptureSessionResource | undefined; + assertAdoptable(): void; + canPersist(): boolean; + adopt(resource: DurableCaptureSessionResource): void; + clear(expected: DurableCaptureSessionResource): 'cleared' | 'retired' | 'resource-changed'; }>; /** @@ -70,14 +64,6 @@ export type DurableCaptureRecordDefinition = Readonly<{ }>; }>; -export type DurableCaptureResourceDefinition< - K extends string, - H extends LiveResourceHandle, - C, - S, -> = DurableCaptureRecordDefinition & - Readonly<{ sessionSlot: DurableCaptureSessionSlot }>; - /** * What the mechanics observed about a failed adoption's cleanup. Reporting it keeps the * admission decision — block a replacement start, or clear an earlier block — with the caller. @@ -86,11 +72,9 @@ export type DurableCaptureCleanupOutcome = | { confirmed: true } | { confirmed: false; reason: string }; -export type AdoptStartedDurableCaptureParams = { +export type AdoptStartedDurableCaptureParams = { reportUndurableCleanup(device: DeviceInfo, outcome: DurableCaptureCleanupOutcome): void; - session: S; - sessionName: string; - sessionStore: DurableCaptureSessionStore; + binding: DurableCaptureSessionBinding; device: DeviceInfo; owner: RuntimeOwnerRef; fence: ResourceOwnershipFence; diff --git a/packages/capture-kit/src/durable-capture/durable-capture.fixtures.ts b/packages/capture-kit/src/durable-capture/durable-capture.fixtures.ts index 61bdf5d280..9496e6845f 100644 --- a/packages/capture-kit/src/durable-capture/durable-capture.fixtures.ts +++ b/packages/capture-kit/src/durable-capture/durable-capture.fixtures.ts @@ -1,3 +1,4 @@ +import { makeCaptureSessionBinding, makeCaptureFixtureStore } from './session-binding.fixtures.ts'; import path from 'node:path'; import { vi, type Mock } from 'vitest'; import { @@ -13,9 +14,8 @@ import { mkdtempForTestSync } from '../tmp-dir.fixtures.ts'; import type { DurableCaptureCleanupOutcome, DurableCaptureFailedFinishPolicy, - DurableCaptureResourceDefinition, + DurableCaptureRecordDefinition, DurableCaptureSessionResource, - DurableCaptureSessionStore, } from './definition.ts'; import { createDurableCaptureResourceStore, type DurableCaptureResourceStore } from './store.ts'; @@ -49,21 +49,12 @@ export const testCaptureStore = createDurableCaptureResourceStore({ export function createTestCaptureDefinition( store: DurableCaptureResourceStore = testCaptureStore, failedFinishPolicy: DurableCaptureFailedFinishPolicy = 'dispose-on-failed-finish', -): DurableCaptureResourceDefinition< - typeof TEST_CAPTURE_KIND, - TestCaptureHandle, - TestCaptureCompletion, - TestCaptureSession -> { +): DurableCaptureRecordDefinition { return { resourceKind: TEST_CAPTURE_KIND, displayName: 'test capture', store, failedFinishPolicy, - sessionSlot: { - read: (session) => session.capture, - replace: (session, capture) => ({ ...session, capture }), - }, completionMetadata: (completion) => ({ outputPath: completion.outputPath, completedAt: completion.completedAt, @@ -87,20 +78,20 @@ export function makeDurableCaptureContext( ) { const sessionsDir = mkdtempForTestSync('durable-capture-resource-'); const sessionName = 'session'; - const sessions = new Map(); const session: TestCaptureSession = { name: sessionName }; - sessions.set(sessionName, session); const resolveSessionDir = (name: string): string => path.join(sessionsDir, name); - const sessionStore: DurableCaptureSessionStore = { - set: (name, next) => void sessions.set(name, next), - resolveSessionDir, - }; + const sessionStore = makeCaptureFixtureStore(resolveSessionDir); + sessionStore.set(sessionName, session); const reportUndurableCleanup: Mock< (device: DeviceInfo, outcome: DurableCaptureCleanupOutcome) => void > = vi.fn(); return { reportUndurableCleanup, - sessions, + binding: makeCaptureSessionBinding(sessionStore, sessionName, { + read: (session) => session.capture, + replace: (session, capture) => ({ ...session, capture }), + }), + sessions: sessionStore, sessionsDir, resolveSessionDir, session, diff --git a/packages/capture-kit/src/durable-capture/index.ts b/packages/capture-kit/src/durable-capture/index.ts index 09013e180a..1d8f84b174 100644 --- a/packages/capture-kit/src/durable-capture/index.ts +++ b/packages/capture-kit/src/durable-capture/index.ts @@ -12,9 +12,8 @@ export type { AdoptStartedDurableCaptureParams, DurableCaptureFinishIntent, DurableCaptureRecordDefinition, - DurableCaptureResourceDefinition, DurableCaptureSessionResource, - DurableCaptureSessionStore, + DurableCaptureSessionBinding, } from './definition.ts'; export type { FinishRecoveredDurableCaptureParams } from './finish-recovered.ts'; export type { diff --git a/packages/capture-kit/src/durable-capture/session-binding.fixtures.ts b/packages/capture-kit/src/durable-capture/session-binding.fixtures.ts new file mode 100644 index 0000000000..e84740ee55 --- /dev/null +++ b/packages/capture-kit/src/durable-capture/session-binding.fixtures.ts @@ -0,0 +1,87 @@ +import { AppError } from '@agent-device/kernel/errors'; +import type { DurableCaptureSessionBinding, DurableCaptureSessionResource } from './definition.ts'; + +type FixtureSessionRef = Readonly<{ address: string; session: S; lifetime: object }>; + +export function makeCaptureFixtureStore(resolveSessionDir: (address: string) => string) { + const entries = new Map(); + const resolveCurrent = (ref: FixtureSessionRef): S | undefined => { + const entry = entries.get(ref.address); + return entry === ref.lifetime ? entry.current : undefined; + }; + return Object.freeze({ + resolveSessionDir, + get: (address: string): S | undefined => entries.get(address)?.current, + set: (address: string, session: S): void => { + const entry = entries.get(address); + if (entry) entry.current = session; + else entries.set(address, { current: session }); + }, + lookup: (address: string): FixtureSessionRef => { + const entry = entries.get(address); + if (!entry) throw new AppError('COMMAND_FAILED', 'Test session retired'); + return Object.freeze({ address, session: entry.current, lifetime: entry }); + }, + resolveCurrent, + update: (ref: FixtureSessionRef, rebuild: (current: S) => S): void => { + const current = resolveCurrent(ref); + if (current === undefined) throw new AppError('COMMAND_FAILED', 'Test session retired'); + entries.get(ref.address)!.current = rebuild(current); + }, + retire: (ref: FixtureSessionRef): boolean => + resolveCurrent(ref) !== undefined && entries.delete(ref.address), + }); +} + +export function makeCaptureSessionBinding( + store: ReturnType>, + address: string, + slot: Readonly<{ + read(session: S): DurableCaptureSessionResource | undefined; + replace(session: S, resource: DurableCaptureSessionResource | undefined): S; + }>, +): DurableCaptureSessionBinding { + const ref = store.lookup(address); + let retained = slot.read(ref.session); + const requireSession = (): S => { + const session = store.resolveCurrent(ref); + if (session === undefined) throw new AppError('COMMAND_FAILED', 'Test session retired'); + return session; + }; + const assertAdoptable = (): void => { + if (slot.read(requireSession())) throw new AppError('COMMAND_FAILED', 'Test resource changed'); + }; + return Object.freeze({ + address, + sessionDir: store.resolveSessionDir(address), + read: () => { + const session = store.resolveCurrent(ref); + if (session !== undefined) retained = slot.read(session); + return retained; + }, + assertAdoptable, + canPersist: () => { + const session = store.resolveCurrent(ref); + return session !== undefined && slot.read(session) === undefined; + }, + adopt: (resource) => { + assertAdoptable(); + store.update(ref, (current) => slot.replace(current, resource)); + retained = resource; + }, + clear: (expected) => { + const current = store.resolveCurrent(ref); + if (current === undefined) return 'retired'; + const active = slot.read(current); + if ( + active?.handle !== expected.handle || + active.envelope.fence.token !== expected.envelope.fence.token || + active.envelope.fence.generation !== expected.envelope.fence.generation + ) + return 'resource-changed'; + store.update(ref, (session) => slot.replace(session, undefined)); + retained = undefined; + return 'cleared'; + }, + }); +} diff --git a/packages/capture-kit/src/durable-capture/transitions.test.ts b/packages/capture-kit/src/durable-capture/transitions.test.ts index 5085197ca1..d50c0e3c02 100644 --- a/packages/capture-kit/src/durable-capture/transitions.test.ts +++ b/packages/capture-kit/src/durable-capture/transitions.test.ts @@ -39,9 +39,7 @@ test('finish failure remains primary when cleanup and cleanup-pending persistenc finishLiveDurableCapture( definition, { - session: active, - sessionName: context.sessionName, - sessionStore: context.sessionStore, + binding: context.binding, intent: 'capture', }, context.resourcePath, @@ -70,9 +68,7 @@ test('an uncertain finish preserves its error after confirmed compensating clean finishLiveDurableCapture( testCaptureDefinition, { - session: active, - sessionName: context.sessionName, - sessionStore: context.sessionStore, + binding: context.binding, intent: 'capture', }, context.resourcePath, @@ -106,9 +102,7 @@ test('an uncertain finish retains live evidence when compensating cleanup is unc finishLiveDurableCapture( testCaptureDefinition, { - session: active, - sessionName: context.sessionName, - sessionStore: context.sessionStore, + binding: context.binding, intent: 'capture', }, context.resourcePath, @@ -145,9 +139,7 @@ test('a preserved finish leaves the record open without disposing what its retry finishLiveDurableCapture( definition, { - session: active, - sessionName: context.sessionName, - sessionStore: context.sessionStore, + binding: context.binding, intent: 'capture', }, context.resourcePath, @@ -181,9 +173,7 @@ test('a preserved finish that reports uncertainty still leaves the record retrya finishLiveDurableCapture( definition, { - session: active, - sessionName: context.sessionName, - sessionStore: context.sessionStore, + binding: context.binding, intent: 'capture', }, context.resourcePath, @@ -216,9 +206,7 @@ test('a disposal finish disposes a preserving kind’s material too', async () = finishLiveDurableCapture( definition, { - session: active, - sessionName: context.sessionName, - sessionStore: context.sessionStore, + binding: context.binding, intent: 'disposal', }, context.resourcePath, @@ -231,3 +219,72 @@ test('a disposal finish disposes a preserving kind’s material too', async () = envelope: { lifecycle: 'completed', metadata: { phase: 'completed' } }, }); }); + +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(); + const start = makeDurableCaptureStartResult(context); + let enter!: () => void; + let release!: () => void; + const entered = new Promise((resolve) => { + enter = resolve; + }); + const resumed = new Promise((resolve) => { + release = resolve; + }); + start.finish.mockImplementationOnce(async () => { + enter(); + await resumed; + return { status: 'completed', result: { outputPath: '/tmp/capture', completedAt: 2 } }; + }); + await adoptStartedDurableCapture( + testCaptureDefinition, + { + ...context, + ...start, + throwIfCanceled: () => {}, + }, + context.resourcePath, + ); + const finishing = finishLiveDurableCapture( + testCaptureDefinition, + { + binding: context.binding, + intent: 'capture', + }, + context.resourcePath, + ); + await entered; + const ref = context.sessionStore.lookup(context.sessionName); + const active = context.sessionStore.get(context.sessionName)!.capture!; + if (change === 'retire') { + context.sessionStore.retire(ref); + context.sessionStore.set(context.sessionName, { name: 'successor', capture: active }); + } else { + const fence = { + ...active.envelope.fence, + ...(change === 'token' ? { token: 'replacement' } : {}), + ...(change === 'generation' ? { generation: active.envelope.fence.generation + 1 } : {}), + }; + context.sessionStore.update(ref, (current) => ({ + ...current, + name: 'updated', + capture: { + ...active, + handle: + change === 'handle' ? makeDurableCaptureStartResult(context).handle : active.handle, + envelope: { ...active.envelope, fence }, + }, + })); + } + const before = context.sessionStore.get(context.sessionName)!; + release(); + await finishing; + const current = context.sessionStore.get(context.sessionName)!; + expect(start.finish).toHaveBeenCalledOnce(); + expect(current.name).toBe(change === 'retire' ? 'successor' : 'updated'); + if (change === 'rebuild') expect(current.capture).toBeUndefined(); + else expect(current).toBe(before); + }, +); diff --git a/packages/capture-kit/src/durable-capture/transitions.ts b/packages/capture-kit/src/durable-capture/transitions.ts index 8e3a2590e2..b6de89d1fb 100644 --- a/packages/capture-kit/src/durable-capture/transitions.ts +++ b/packages/capture-kit/src/durable-capture/transitions.ts @@ -12,8 +12,7 @@ import { withDurableCaptureResourceFence, type DurableCaptureResourceFenceLease import type { DurableCaptureFinishIntent, DurableCaptureRecordDefinition, - DurableCaptureResourceDefinition, - DurableCaptureSessionStore, + DurableCaptureSessionBinding, } from './definition.ts'; import { capitalizeDurableCaptureLabel, durableCaptureDiagnosticPrefix } from './labels.ts'; @@ -21,18 +20,15 @@ export async function finishLiveDurableCapture< K extends string, H extends LiveResourceHandle, C, - S, >( - definition: DurableCaptureResourceDefinition, + definition: DurableCaptureRecordDefinition, params: { - session: S; - sessionName: string; - sessionStore: DurableCaptureSessionStore; + binding: DurableCaptureSessionBinding; intent: DurableCaptureFinishIntent; }, resourcePath: string, ): Promise { - const active = definition.sessionSlot.read(params.session); + const active = params.binding.read(); if (!active) throw new AppError('INVALID_ARGS', definition.messages.noActive); try { const result = await finishDurableCaptureHandle(definition, { @@ -41,12 +37,12 @@ export async function finishLiveDurableCapture< resourcePath, intent: params.intent, }); - clearLiveSlot(definition, params); + params.binding.clear(active); return result; } catch (error) { const record = definition.store.read(resourcePath); if (record.status === 'decoded' && record.envelope.lifecycle === 'completed') { - clearLiveSlot(definition, params); + params.binding.clear(active); } throw error; } @@ -186,16 +182,6 @@ function emitFailedFinishCleanupDiagnostic( }); } -function clearLiveSlot, C, S>( - definition: DurableCaptureResourceDefinition, - params: { session: S; sessionName: string; sessionStore: DurableCaptureSessionStore }, -): void { - params.sessionStore.set( - params.sessionName, - definition.sessionSlot.replace(params.session, undefined), - ); -} - function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } @@ -204,17 +190,14 @@ export async function forceCleanupLiveDurableCapture< K extends string, H extends LiveResourceHandle, C, - S, >( - definition: DurableCaptureResourceDefinition, + definition: DurableCaptureRecordDefinition, params: { - session: S; - sessionName?: string; - sessionStore?: DurableCaptureSessionStore; + binding: DurableCaptureSessionBinding; resourcePath: string; }, ): Promise { - const active = definition.sessionSlot.read(params.session); + const active = params.binding.read(); if (!active) return; const outcome = await withDurableCaptureResourceFence({ store: definition.store, @@ -228,12 +211,7 @@ export async function forceCleanupLiveDurableCapture< }, }); requireConfirmedDurableCaptureCleanup(definition, outcome); - if (params.sessionStore && params.sessionName) { - params.sessionStore.set( - params.sessionName, - definition.sessionSlot.replace(params.session, undefined), - ); - } + params.binding.clear(active); } export function transitionCleanupOutcome( diff --git a/packages/host-kit/src/internal/owner-identity-liveness.test.ts b/packages/host-kit/src/internal/owner-identity-liveness.test.ts index fd5278afa8..511da64644 100644 --- a/packages/host-kit/src/internal/owner-identity-liveness.test.ts +++ b/packages/host-kit/src/internal/owner-identity-liveness.test.ts @@ -59,3 +59,13 @@ test('a missing entry in a completed process snapshot stays fail-closed without assert.equal(mockIsProcessZombie.mock.calls.length, 0); assert.equal(mockReadProcessStartTime.mock.calls.length, 0); }); + +test('a pid outside the native range is unknown without a liveness probe', () => { + assert.equal( + classifyOwnerLiveness({ owner: { pid: 2_147_483_648, startTime: 'start-a' } }), + 'unknown', + ); + assert.equal(mockIsProcessAlive.mock.calls.length, 0); + assert.equal(mockIsProcessZombie.mock.calls.length, 0); + assert.equal(mockReadProcessStartTime.mock.calls.length, 0); +}); diff --git a/packages/host-kit/src/internal/owner-identity.ts b/packages/host-kit/src/internal/owner-identity.ts index a7b1352b35..552a030ea7 100644 --- a/packages/host-kit/src/internal/owner-identity.ts +++ b/packages/host-kit/src/internal/owner-identity.ts @@ -11,6 +11,11 @@ export type OwnerIdentity = { startTime: string | null; }; +/** A process id accepted by Node's native signal API. */ +export function isProcessPid(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value > 0 && value <= 0x7fff_ffff; +} + export type OwnerLiveness = | 'live' | 'owner-process-dead' @@ -75,6 +80,7 @@ export function classifyOwnerLivenessFromObservation( observation?: HostProcessIdentityObservation | null, ): OwnerLiveness { const { owner, stateDir } = params; + if (!isProcessPid(owner.pid)) return 'unknown'; if (!isProcessAlive(owner.pid)) return 'owner-process-dead'; if (observation !== undefined ? observation?.state.startsWith('Z') : isProcessZombie(owner.pid)) { return 'owner-process-dead'; @@ -88,7 +94,10 @@ export function classifyOwnerLivenessFromObservation( return 'owner-process-reused'; } } - if (!stateDir) return 'live'; + return stateDir ? classifyOwnerStateDirectory(stateDir) : 'live'; +} + +function classifyOwnerStateDirectory(stateDir: string): OwnerLiveness { try { fs.statSync(stateDir); return 'live'; diff --git a/packages/host-kit/src/internal/process-lock.ts b/packages/host-kit/src/internal/process-lock.ts index 989b28a78d..3108c208e1 100644 --- a/packages/host-kit/src/internal/process-lock.ts +++ b/packages/host-kit/src/internal/process-lock.ts @@ -6,6 +6,7 @@ import { publishFileSync } from './atomic-file.ts'; import { emitDiagnostic } from './diagnostics.ts'; import { classifyOwnerLiveness, + isProcessPid, ownerIdentityMatches, type OwnerLiveness, } from './owner-identity.ts'; @@ -553,7 +554,7 @@ function parseProcessLockOwner(contents: string): ProcessLockOwnerRecord | null const PROCESS_LOCK_OWNER_FIELD_SHAPES: Record boolean> = { - pid: (value) => typeof value === 'number' && Number.isInteger(value) && value > 0, + pid: isProcessPid, acquiredAtMs: (value) => typeof value === 'number' && Number.isFinite(value), startTime: (value) => value === undefined || value === null || typeof value === 'string', }; diff --git a/packages/host-kit/src/process.ts b/packages/host-kit/src/process.ts index 2ed4c2c06d..6c061dd11f 100644 --- a/packages/host-kit/src/process.ts +++ b/packages/host-kit/src/process.ts @@ -36,6 +36,7 @@ export { export { classifyOwnerLiveness, classifyOwnerLivenessFromObservation, + isProcessPid, type OwnerIdentity, ownerIdentityDiffers, ownerIdentityMatches, diff --git a/packages/host-kit/src/session-paths.test.ts b/packages/host-kit/src/session-paths.test.ts new file mode 100644 index 0000000000..0b443d3452 --- /dev/null +++ b/packages/host-kit/src/session-paths.test.ts @@ -0,0 +1,18 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +// oxlint-disable-next-line no-restricted-imports -- asserts a path under os.homedir +import os from 'node:os'; +import path from 'node:path'; +import { expandSessionPath } from './session-paths.ts'; + +test('expandSessionPath resolves tilde, relative-with-cwd, and absolute paths', () => { + const homePath = expandSessionPath('~/flows/replay.ad'); + assert.equal(homePath, path.join(os.homedir(), 'flows', 'replay.ad')); + + const relativePath = expandSessionPath('workflows/replay.ad', '/tmp/agent-device-cwd'); + assert.equal(relativePath, path.resolve('/tmp/agent-device-cwd', 'workflows/replay.ad')); + + const absoluteInput = path.resolve('/tmp', 'agent-device-absolute.ad'); + const absolutePath = expandSessionPath(absoluteInput, '/tmp/ignored-cwd'); + assert.equal(absolutePath, absoluteInput); +}); diff --git a/packages/platform-android/src/recording/failed-finish.test.ts b/packages/platform-android/src/recording/failed-finish.test.ts index 4c4984020c..38f9be8955 100644 --- a/packages/platform-android/src/recording/failed-finish.test.ts +++ b/packages/platform-android/src/recording/failed-finish.test.ts @@ -12,8 +12,7 @@ import { adoptStartedDurableCapture, createDurableCaptureResourceStore, finishLiveDurableCapture, - type DurableCaptureResourceDefinition, - type DurableCaptureSessionStore, + type DurableCaptureRecordDefinition, } from '@agent-device/capture-kit/durable-capture'; import { mkdtempForTestSync } from '../__tests__/test-utils/tmp-dir.ts'; import { androidRecordingDevice, recordingHost, recordingInput } from './fixtures.ts'; @@ -114,7 +113,7 @@ type AndroidRecordingSession = Readonly<{ /** * The daemon's recording record assembled around the real Android handle: the same definition the - * daemon declares in `src/daemon/screen-recording-session-resource.ts`, including its policy, + * daemon declares in `packages/capture-kit/src/capture-admission/screen-recording-session-resource.ts`, including its policy, * driving the shared coordinator. */ async function adoptAndroidRecording(params: { @@ -128,42 +127,50 @@ async function adoptAndroidRecording(params: { fileName: 'screen-recording.resource.json', displayName: 'screen recording', }); - const definition: DurableCaptureResourceDefinition< - 'screen-recording', - ScreenRecordingLiveHandle, - ScreenRecordingCompletion, - AndroidRecordingSession - > = { - resourceKind: 'screen-recording', - displayName: 'screen recording', - store, - failedFinishPolicy: 'preserve-retry-material', - sessionSlot: { - read: (session) => session.recording, - replace: (session, recording) => ({ ...session, recording }), - }, - completionMetadata: (completion) => ({ outPath: completion.outPath }), - messages: { - noActive: 'no active recording', - cleanupPendingHint: 'Keep screen-recording.resource.json and retry stop.', - }, - }; + const definition: DurableCaptureRecordDefinition<'screen-recording', ScreenRecordingCompletion> = + { + resourceKind: 'screen-recording', + displayName: 'screen recording', + store, + failedFinishPolicy: 'preserve-retry-material', + completionMetadata: (completion) => ({ outPath: completion.outPath }), + messages: { + noActive: 'no active recording', + cleanupPendingHint: 'Keep screen-recording.resource.json and retry stop.', + }, + }; const sessionsDir = mkdtempForTestSync('agent-device-android-failed-finish-session-'); let session: AndroidRecordingSession = {}; - const sessionStore: DurableCaptureSessionStore = { - set: (_name, next) => { + const sessionStore = { + get: () => session, + set: (_name: string, next: AndroidRecordingSession) => { session = next; }, - resolveSessionDir: (name) => path.join(sessionsDir, name), + resolveSessionDir: (name: string) => path.join(sessionsDir, name), + }; + const binding = { + address: params.sessionName, + sessionDir: sessionStore.resolveSessionDir(params.sessionName), + read: () => session.recording, + assertAdoptable: () => { + if (session.recording) throw new Error('Already recording'); + }, + canPersist: () => !session.recording, + adopt: (recording: AndroidRecordingSession['recording']) => { + session = { ...session, recording }; + }, + clear: (expected: NonNullable) => { + if (session.recording?.handle !== expected.handle) return 'resource-changed' as const; + session = { ...session, recording: undefined }; + return 'cleared' as const; + }, }; - const resourcePath = store.resolvePath(sessionStore.resolveSessionDir(params.sessionName)); + const resourcePath = store.resolvePath(binding.sessionDir); await adoptStartedDurableCapture( definition, { reportUndurableCleanup: () => {}, - session, - sessionName: params.sessionName, - sessionStore, + binding, device: androidRecordingDevice, owner: params.owner, fence: params.envelope.fence, @@ -178,9 +185,7 @@ async function adoptAndroidRecording(params: { finishLiveDurableCapture( definition, { - session, - sessionName: params.sessionName, - sessionStore, + binding, intent: 'capture', }, resourcePath, diff --git a/packages/replay-port/src/daemon-port/command-types.ts b/packages/replay-port/src/daemon-port/command-types.ts index e34948b0f2..f9214bf21e 100644 --- a/packages/replay-port/src/daemon-port/command-types.ts +++ b/packages/replay-port/src/daemon-port/command-types.ts @@ -54,11 +54,13 @@ export type ReplaySessionStore = Readonly<{ */ export type ReplaySessionObservation = Readonly<{ get: () => ReplaySessionState | undefined; - bindAuthority: ReplayObservationAuthorityBinder; - capture: (params: { - flags: DaemonWireRequest['flags']; - logPath: string; - }) => Promise<{ snapshot: SnapshotState }>; + bindAuthority: (signal?: AbortSignal) => ReturnType & + Readonly<{ + capture: (params: { + flags: DaemonWireRequest['flags']; + logPath: string; + }) => Promise<{ snapshot: SnapshotState }>; + }>; }>; /** Immutable read projection of the repair-transaction fields the coordinator's writers touch. */ diff --git a/packages/replay-port/src/daemon-port/replay-session-binding.ts b/packages/replay-port/src/daemon-port/replay-session-binding.ts index d7f2d66108..51345c93fc 100644 --- a/packages/replay-port/src/daemon-port/replay-session-binding.ts +++ b/packages/replay-port/src/daemon-port/replay-session-binding.ts @@ -1,4 +1,3 @@ -import type { ReplayObservationAuthorityBinder } from '@agent-device/contracts/replay'; import type { ReplayCoordinator, ReplaySession, @@ -26,8 +25,7 @@ export type ReplaySessionPolicy = Readonly<{ createCoordinator: () => ReplayCoordinator; assertSelectorMatches: ReplaySessionStore['assertSelectorMatches']; resolveOpenRuntimeHints: ReplaySessionStore['resolveOpenRuntimeHints']; - bindAuthority: ReplayObservationAuthorityBinder; - capture: ReplaySessionObservation['capture']; + bindAuthority: ReplaySessionObservation['bindAuthority']; }>; /** @@ -53,7 +51,6 @@ export function bindReplaySession( observationStore: { get: container.get, bindAuthority: policy.bindAuthority, - capture: policy.capture, }, coordinator: policy.createCoordinator(), }; diff --git a/packages/replay-port/src/daemon-port/session-replay-divergence.ts b/packages/replay-port/src/daemon-port/session-replay-divergence.ts index f24caa1d61..6551d8fd67 100644 --- a/packages/replay-port/src/daemon-port/session-replay-divergence.ts +++ b/packages/replay-port/src/daemon-port/session-replay-divergence.ts @@ -256,10 +256,11 @@ export async function captureDivergenceObservation(params: { // shrinks the retry budget rather than getting a free `DEADLINE_MS` on top // of however long it took. const deadline = Date.now() + DIVERGENCE_CAPTURE_RETRY_DEADLINE_MS; + const observation = observationStore.bindAuthority(); let attempt = await captureDivergenceObservationAttempt({ session, - observationStore, + observation, logPath, flags, }); @@ -272,7 +273,7 @@ export async function captureDivergenceObservation(params: { await sleep(Math.min(delayMs, remainingMs)); attempt = await captureDivergenceObservationAttempt({ session, - observationStore, + observation, logPath, flags, }); @@ -298,13 +299,13 @@ type DivergenceCaptureAttempt = { async function captureDivergenceObservationAttempt(params: { session: ReplaySessionState; - observationStore: ReplaySessionObservation; + observation: ReturnType; logPath: string; flags: CommandFlags; }): Promise { - const { session, observationStore, logPath, flags } = params; + const { session, observation, logPath, flags } = params; try { - const capture = await observationStore.capture({ flags, logPath }); + const capture = await observation.capture({ flags, logPath }); const snapshot = capture.snapshot; if (isSparseSnapshotQualityVerdict(snapshot.snapshotQuality)) { return { @@ -316,8 +317,7 @@ async function captureDivergenceObservationAttempt(params: { retryable: true, }; } - const observationAuthority = observationStore.bindAuthority(); - const stored = observationAuthority.store(snapshot); + const stored = observation.store(snapshot); return { observation: { state: 'available', 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/check.ts b/scripts/layering/check.ts index b3512b504f..dda8227948 100644 --- a/scripts/layering/check.ts +++ b/scripts/layering/check.ts @@ -22,7 +22,7 @@ // - Over the RANKED SPINE only: rejection of every spine back-edge (R5), i.e. // an import whose source zone outranks its target zone, plus a ratchet on the // same inversion measured over TYPE-ONLY edges (R6). -// - Over the DAEMON only: SessionState field ownership (R7), because the session +// - Over the DAEMON and its capture-admission adapters: SessionState field ownership (R7), because the session // record is store-owned mutable state that any daemon module can write; and the terminal // concrete-platform boundary (R65), which rejects every import form into the retired // src/platforms path or a platform package. @@ -315,6 +315,24 @@ function checkSessionStateOwnership(sources: ReadonlyMap): Layer }); continue; } + const syntaxFailures: Readonly> = { + '[patch-shape]': + 'Session updates require an explicit patch literal or inline synchronous callback returning named keys. Computed keys, spreads, getters, async callbacks and opaque patches cannot establish field ownership.', + '[reentrant-patch]': + 'A session patch callback must not call back into the store. Read the supplied current record and return named fields synchronously.', + '[whole-record-spread]': + 'Whole SessionState copies are allowed only inside the store or declared draft constructors. Update an existing lifetime through its field owner with a named patch.', + }; + const syntaxFailure = syntaxFailures[write.field]; + if (syntaxFailure) { + violations.push({ + rule: 'R7 session-state-ownership', + file: write.file, + line: write.line, + message: syntaxFailure, + }); + continue; + } if (owners === undefined) { const storeOwned = STORE_OWNED_SESSION_STATE_FIELDS.has(write.field); violations.push({ @@ -324,7 +342,7 @@ function checkSessionStateOwnership(sources: ReadonlyMap): Layer message: storeOwned ? `session.${write.field} is classified store-established ` + `(STORE_OWNED_SESSION_STATE_FIELDS), meaning nothing mutates it after construction — ` + - `but this is a direct write. Route it through the store, or move the field into ` + + `but this is a write. Route it through the store, or move the field into ` + `SESSION_STATE_FIELD_OWNERS with this module as its owner.` : `session.${write.field} has no declared owner. SessionStore hands out the live ` + `record, so this write is durable: name the owning module in ` + diff --git a/scripts/layering/session-resource-ownership.test.ts b/scripts/layering/session-resource-ownership.test.ts index ffac560315..a85922db6c 100644 --- a/scripts/layering/session-resource-ownership.test.ts +++ b/scripts/layering/session-resource-ownership.test.ts @@ -19,11 +19,22 @@ test('session resources are constructed only by their durable domain owners', () appLogFailure: failure, audioProbe: audio, perfCapture: perf, + screenRecording: recording, });`, ], [ 'src/daemon/app-log-session-resource.ts', - `sessionStore.set(name, { ...session, appLog: log, appLogFailure: undefined });`, + `sessionStore.update(ref, { appLog: log, appLogFailure: undefined });`, + ], + [ + 'src/daemon/session-capture-binding.ts', + `sessionStore.update(ref, { audioProbe: audio }); + 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', @@ -39,6 +50,9 @@ test('session resources are constructed only by their durable domain owners', () 'src/daemon/handlers/planted.ts: session appLogFailure record constructed outside its owner', 'src/daemon/handlers/planted.ts: session audioProbe record constructed outside its owner', 'src/daemon/handlers/planted.ts: session perfCapture record constructed outside its owner', + 'src/daemon/handlers/planted.ts: session screenRecording record constructed outside its owner', + 'packages/capture-kit/src/capture-admission/audio-probe-session-resource.ts: session audioProbe record constructed outside its owner', + 'packages/capture-kit/src/capture-admission/perf-capture-session-resource.ts: session perfCapture record constructed outside its owner', ], ); }); diff --git a/scripts/layering/session-resource-ownership.ts b/scripts/layering/session-resource-ownership.ts index 4990fdef9e..77aa399444 100644 --- a/scripts/layering/session-resource-ownership.ts +++ b/scripts/layering/session-resource-ownership.ts @@ -1,4 +1,4 @@ -// Catches: a session resource field (appLog, appLogFailure, audioProbe, perfCapture) written +// Catches: a session resource field (appLog, appLogFailure, audioProbe, perfCapture, screenRecording) written // from outside its declared owner module — R7's session-state-ownership shape applied to the // narrower set of per-resource fields these session-scoped runtimes carry, where the same // aliasing hazard (get()/set() hand back and re-put the live reference) applies. @@ -27,14 +27,13 @@ const SCANNED_ROOTS = ['src/daemon/', 'packages/capture-kit/src/capture-admissio const RESOURCE_OWNERS: Readonly>> = { appLog: new Set(['src/daemon/app-log-session-resource.ts', 'src/daemon/session-state.ts']), appLogFailure: new Set(['src/daemon/app-log-session-resource.ts', 'src/daemon/session-state.ts']), - audioProbe: new Set([ - 'packages/capture-kit/src/capture-admission/audio-probe-session-resource.ts', - 'src/daemon/session-state.ts', - ]), - perfCapture: new Set([ - 'packages/capture-kit/src/capture-admission/perf-capture-session-resource.ts', + 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']), }; /** Durable session-resource records have one whole-record construction owner per domain. */ diff --git a/scripts/layering/session-state.test.ts b/scripts/layering/session-state.test.ts new file mode 100644 index 0000000000..e243df258e --- /dev/null +++ b/scripts/layering/session-state.test.ts @@ -0,0 +1,245 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + findSessionStateWrites, + SESSION_STATE_FIELD_OWNERS, + sessionStateWritePressure, +} from './session-state.ts'; + +const OWNER = 'src/daemon/app-log-session-resource.ts'; +const FIELDS = ['appLog', 'appLogFailure', 'lease', 'lastPerfProfile']; +function scan(source: string, file = OWNER) { + return findSessionStateWrites(new Map([[file, source]]), FIELDS); +} + +test('explicit owner patches name their writes and retain direct assignment checks', () => { + assert.deepEqual( + scan('store.update(ref, { appLogFailure: undefined });\nsession.appLog = log;').map((w) => [ + w.field, + w.line, + ]), + [ + ['appLogFailure', 1], + ['appLog', 2], + ], + ); + assert.ok(SESSION_STATE_FIELD_OWNERS.appLogFailure!.includes(OWNER)); + const foreign = scan( + 'sessionStore.update(ref, { appLogFailure: error });', + 'src/daemon/handlers/probe.ts', + )[0]!; + assert.equal(foreign.field, 'appLogFailure'); + assert.ok(!SESSION_STATE_FIELD_OWNERS[foreign.field]!.includes(foreign.file)); +}); + +test('inline synchronous patches can derive named fields with nested value spreads', () => { + for (const patch of [ + '(current) => ({ lease: { ...current.lease, expiresAt: 10 } })', + '(current) => { const expiresAt = 10; return { lease: { ...current.lease, expiresAt } }; }', + 'function(current) { return { lease: { ...current.lease, expiresAt: 10 } }; }', + ]) + assert.deepEqual( + scan(`store.update(ref, ${patch});`).map((w) => w.field), + ['lease'], + patch, + ); +}); + +for (const patch of [ + '{ [key]: value }', + '{ ...changes }', + 'changes', + 'rebuild', + 'async (current) => ({ appLogFailure: undefined })', + '(current) => changes', + '(current) => { if (test) return changes; return { appLogFailure: undefined }; }', + '{ get appLogFailure() { return error; } }', +]) { + test(`update rejects unattributable patch ${patch}`, () => { + assert.ok(scan(`store.update(ref, ${patch});`).some((w) => w.field === '[patch-shape]')); + }); +} + +test('patch callbacks cannot call back into the session store', () => { + const writes = scan( + 'store.update(ref, (current) => { store.retire(ref); return { appLogFailure: undefined }; });', + ); + assert.ok(writes.some((w) => w.field === '[reentrant-patch]')); + assert.ok(writes.some((w) => w.field === 'appLogFailure')); +}); + +test('the historical app-log whole-record spread is refused and counted fairly', () => { + const writes = scan( + 'const session = sessionStore.get(address); sessionStore.set(address, { ...session, appLogFailure: error });', + ); + assert.deepEqual( + writes.map((w) => w.field), + ['[whole-record-spread]', 'appLogFailure'], + ); +}); + +test('record copies through a read alias or captured ref remain visible', () => { + for (const source of [ + 'const refreshed = params.sessionStore.get(address) ?? session; return { ...refreshed, lastPerfProfile: profile };', + 'const previous: SessionState = value; return { ...previous, lastPerfProfile: profile };', + 'return { ...ref.session, lastPerfProfile: profile };', + ]) + assert.deepEqual( + scan(source).map((w) => w.field), + ['[whole-record-spread]', 'lastPerfProfile'], + source, + ); +}); + +test('typed store aliases enforce the same explicit patch contract', () => { + assert.deepEqual( + scan( + 'function update(storage: SessionStore) { storage.update(ref, { appLogFailure: error }); }', + ).map((w) => w.field), + ['appLogFailure'], + ); + assert.deepEqual( + scan('function update(storage: SessionStore) { storage.update(ref, changes); }').map( + (w) => w.field, + ), + ['[patch-shape]'], + ); +}); + +test('plain store and record aliases retain their owning identity', () => { + assert.deepEqual( + scan('const storage = sessionStore; storage.update(ref, { appLogFailure: error });').map( + (w) => w.field, + ), + ['appLogFailure'], + ); + for (const source of [ + 'const current = ref.session; return { ...current };', + 'const current = session; return { ...current };', + ]) { + assert.deepEqual( + scan(source).map((w) => w.field), + ['[whole-record-spread]'], + source, + ); + } +}); + +test('destructuring preserves store and record aliases', () => { + for (const pattern of ['{ session: current }', '{ session: current = fallback }']) { + assert.deepEqual( + scan(`const ${pattern} = ref; return { ...current, appLogFailure: error };`).map( + (w) => w.field, + ), + ['[whole-record-spread]', 'appLogFailure'], + ); + } + assert.deepEqual( + scan('function copy({ session: current }: SessionRef) { return { ...current }; }').map( + (w) => w.field, + ), + ['[whole-record-spread]'], + ); + assert.deepEqual( + scan('const { sessionStore: storage } = params; storage.update(ref, changes);').map( + (w) => w.field, + ), + ['[patch-shape]'], + ); +}); + +test('alias declarations and patch parameters are collected before checking writes', () => { + assert.deepEqual( + scan( + 'function patch() { const nested = storage; nested.update(ref, changes); } const storage = sessionStore;', + ).map((w) => w.field), + ['[patch-shape]'], + ); + assert.deepEqual( + scan( + 'store.update(ref, (current) => { const entry = current; entry.lastPerfProfile = profile; return { appLogFailure: undefined }; });', + ).map((w) => w.field), + ['appLogFailure', 'lastPerfProfile'], + ); +}); + +test('draft exceptions cover only the declared constructors and fresh open publication', () => { + for (const [file, constructor] of [ + ['src/daemon/snapshot-session.ts', 'createSnapshotSession'], + ['src/daemon/handlers/record-runtime.ts', 'createRecordOnlySession'], + ]) { + assert.deepEqual( + scan( + `function ${constructor}(session: SessionState) { return { ...session, appLogFailure: undefined }; }`, + file, + ), + [], + ); + assert.ok( + scan('function updateSession(session: SessionState) { return { ...session }; }', file).some( + (w) => w.field === '[whole-record-spread]', + ), + ); + } + const open = 'src/daemon/session-lifecycle/internal/session-open-state.ts'; + assert.deepEqual( + scan( + 'function publishOpenSession(session: SessionState) { return store.publish(address, { ...session }); }', + open, + ), + [], + ); + assert.ok( + scan( + 'function publishOpenSession(session: SessionState) { return store.update(ref, { ...session }); }', + open, + ).some((w) => w.field === '[patch-shape]'), + ); + assert.ok( + scan( + 'function publishOpenSession(session: SessionState) { const next = { ...session }; return next; }', + open, + ).some((w) => w.field === '[whole-record-spread]'), + ); +}); + +test('the owning store can merge records, while unrelated updates and platform sessions are excluded', () => { + assert.deepEqual( + scan( + 'sessionStore.update(ref, changes); session.appLogFailure = error;', + 'src/daemon/session-store.ts', + ), + [], + ); + assert.deepEqual(scan('coordinator.update((session) => ({})); hash.update(data);'), []); + assert.deepEqual( + scan('return { ...session, appLogFailure: error };', 'packages/platform-apple/src/session.ts'), + [], + ); +}); + +test('pressure counts capture-kit record replacement and daemon patches with the same syntax rules', () => { + const declaration = + 'export type SessionState = {\n appLogFailure?: Error;\n lease?: object;\n};'; + const baseline = new Map([ + ['src/daemon/session-state.ts', declaration], + ['src/daemon/owner.ts', 'session.appLogFailure = error;'], + [ + 'packages/capture-kit/src/capture-admission/owner.ts', + 'const next = { ...session, appLogFailure: error };', + ], + ]); + const current = new Map([ + ['src/daemon/session-state.ts', declaration], + [OWNER, 'store.update(ref, { appLogFailure: error });'], + ['src/daemon/invalid.ts', 'store.update(ref, changes);'], + ]); + assert.deepEqual(sessionStateWritePressure(baseline), { + writerOwnedFields: 1, + ownerFileClaims: 2, + }); + assert.deepEqual(sessionStateWritePressure(current), { + writerOwnedFields: 1, + ownerFileClaims: 1, + }); +}); diff --git a/scripts/layering/session-state.ts b/scripts/layering/session-state.ts index b13653694f..674026f507 100644 --- a/scripts/layering/session-state.ts +++ b/scripts/layering/session-state.ts @@ -1,43 +1,7 @@ -// Catches: a daemon module writing a SessionState field it does not own — aliasing through -// SessionStore.get()/set() lets any module mutate store-owned state, and only a full-graph -// AST walk over every assignment site (not a review of one module) can tell whose write -// it was. -// Evidence: PR #1392 (e8b779cb32) fixed a close-time script-save failure leaking the session/ -// device claim — a symptom of unowned SessionState writes; the field-owner table this file -// enforces is the durable fix. -// Cost: 262 LOC (no dedicated test file; exercised through daemon-modularity.test.ts and -// model.test.ts). -// Kill criterion: none enforced today; retire only by maintainer decision that per-field -// SessionState write ownership no longer matters. SessionStore hands out the live record -// through get()/set(), so a `session. =` from any module type-checks; no owner exists -// at the type level. -// -// R7 session-state ownership. -// -// `SessionStore.get()` hands back the live `SessionState` out of a private Map, and `set()` -// re-puts the same reference — so a `session. = …` anywhere in the daemon is a durable -// write to store-owned state, and whether it persists depends on aliasing rather than on an -// API call. That is workable while each field has an owner that keeps its invariants; it stops -// being workable the moment a field's rule is spread across modules, because nothing at the -// store boundary can check it. -// -// So the ownership is written down here and enforced. The table is not an aspiration: it is -// the set of writers that exist, so the gate's job is to stop the set from growing quietly. -// Adding a field to `SessionState` forces a deliberate owner; writing an existing field from -// a new module fails until that module is either declared an owner or, better, calls the -// owner instead. ADR 0014's ref frame is the worked example, and the one that has since been -// taken further than this table can go: its four fields moved together across two modules -// until `activateRefFrame` took the transition, and they are now a single value whose nominal -// type no other module can construct, edit, or derive from an existing frame. -// -// Detection is AST-based (`oxc-parser`, already a devDependency) rather than a line regex. A -// regex has to enumerate assignment operators, and the ones it forgets are exactly the ones -// that slip through: `??=` on an optional field is the natural way to write a default, and a -// computed `session[key] =` hides the field name entirely. The parser reports every -// assignment and update form for free, and a `session.a.b = …` sub-object write is reported -// as what it is rather than mistaken for a write to `a`. +/** SessionState field ownership for assignments, named patches and record copies. */ import { parseSync } from 'oxc-parser'; +import { memberName, memberPath, propertyName, visitAst } from './layering-ast.ts'; import path from 'node:path'; export type SessionStateWrite = { @@ -89,15 +53,33 @@ export const SESSION_STATE_FIELD_OWNERS: Readonlyplaceholder registry is populated and consulted only at the @@ -118,23 +100,14 @@ export const SESSION_STATE_FIELD_OWNERS: Readonly = new Set([ 'actions', - 'appBundleId', - 'appLog', - 'appLogFailure', - 'audioProbe', 'createdAt', - 'device', // #2833: the request path reports session activity through `SessionStore.noteSessionActivity`, so // the only writer of this field is the store that owns the record. 'lastActivityAtMs', - 'lastPerfProfile', 'name', 'recordOnlySession', - 'perfCapture', - 'screenRecording', 'sessionScope', 'snapshotDiagnostics', - 'surface', ]); export function sessionStateFieldCount(): number { @@ -251,61 +224,257 @@ function lineOf(source: string, offset: number): number { return line; } -/** - * Every write to a declared `SessionState` field through a binding named `session`, in any - * assignment or update form. `session-store.ts` is excluded: it owns the record and may write - * anything on it. - * - * A computed write (`session[key] = …`) cannot be attributed to a field, so it is reported - * against the sentinel field name `[computed]` — which has no owner and therefore fails, - * rather than passing unnoticed. - */ +type AstNode = Record; + +function astNode(value: unknown): AstNode | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as AstNode) + : undefined; +} + +function unwrapExpression(value: unknown): AstNode | undefined { + let node = astNode(value); + while ( + node && + [ + 'TSAsExpression', + 'TSSatisfiesExpression', + 'TSNonNullExpression', + 'ParenthesizedExpression', + ].includes(String(node.type)) + ) { + node = astNode(node.expression); + } + return node; +} + +function isSessionStoreReceiver(value: unknown, storeBindings: ReadonlySet): boolean { + const members = memberPath(value); + return members !== undefined && storeBindings.has(members.at(-1)!); +} + +function isSessionRecord(value: unknown, sessionBindings: ReadonlySet): boolean { + const node = unwrapExpression(value); + return node?.type === 'Identifier' + ? sessionBindings.has(String(node.name)) + : node?.type === 'MemberExpression' && memberName(node) === 'session'; +} + +function isSessionRead(value: unknown, storeBindings: ReadonlySet): boolean { + const node = unwrapExpression(value); + if (!node) return false; + if (node.type === 'LogicalExpression') return isSessionRead(node.left, storeBindings); + const callee = astNode(node.callee); + return ( + node.type === 'CallExpression' && + callee?.type === 'MemberExpression' && + ['get', 'requireCurrent', 'resolveCurrent'].includes(memberName(callee) ?? '') && + isSessionStoreReceiver(callee.object, storeBindings) + ); +} + +function hasNamedType(node: AstNode, name: string): boolean { + const annotation = astNode(astNode(node.typeAnnotation)?.typeAnnotation); + return annotation?.type === 'TSTypeReference' && propertyName(annotation.typeName) === name; +} + +function patchObjects(value: unknown): AstNode[] | undefined { + const patch = unwrapExpression(value); + if (!patch) return undefined; + if (patch.type === 'ObjectExpression') return [patch]; + if ( + !['ArrowFunctionExpression', 'FunctionExpression'].includes(String(patch.type)) || + patch.async === true + ) + return undefined; + const body = unwrapExpression(patch.body); + if (body?.type === 'ObjectExpression') return [body]; + if (body?.type !== 'BlockStatement') return undefined; + const returns: AstNode[] = []; + visitAst(body, (node) => { + if (node.type === 'ReturnStatement') returns.push(node); + }); + if (returns.length !== 1 || !(body.body as AstNode[]).includes(returns[0]!)) return undefined; + const result = unwrapExpression(returns[0]!.argument); + return result?.type === 'ObjectExpression' ? [result] : undefined; +} + +const SESSION_STATE_SCAN_ROOTS = ['src/daemon/', 'packages/capture-kit/src/capture-admission/']; +const SESSION_DRAFT_CONSTRUCTORS: Readonly> = { + 'src/daemon/session-lifecycle/internal/session-open-state.ts': 'publishOpenSession', + 'src/daemon/snapshot-session.ts': 'createSnapshotSession', + 'src/daemon/handlers/record-runtime.ts': 'createRecordOnlySession', +}; + +/** Assignments, named update keys and whole-record copies at the session ownership seam. */ export function findSessionStateWrites( sources: ReadonlyMap, fields: readonly string[], ): SessionStateWrite[] { const declared = new Set(fields); const writes: SessionStateWrite[] = []; - for (const [file, source] of sources) { - if (!file.startsWith('src/daemon/')) continue; + if (!SESSION_STATE_SCAN_ROOTS.some((root) => file.startsWith(root))) continue; if (path.posix.basename(file) === 'session-store.ts') continue; - - const parsed = parseSync(file, source); - const visit = (node: unknown): void => { - if (node === null || typeof node !== 'object') return; - if (Array.isArray(node)) { - for (const child of node) visit(child); + const program = parseSync(file, source).program; + const sessionBindings = new Set(); + const storeBindings = new Set(['store', 'sessionStore']); + const aliases: Array = []; + const patches = new Set(); + const report = (node: AstNode, field: string): void => { + writes.push({ file, line: lineOf(source, Number(node.start ?? 0)), field }); + }; + visitAst(program, (node) => { + if (node.type === 'Identifier') { + if (isSessionBinding(String(node.name)) || hasNamedType(node, 'SessionState')) + sessionBindings.add(String(node.name)); + if (hasNamedType(node, 'SessionStore')) storeBindings.add(String(node.name)); + } + if (node.type === 'VariableDeclarator') { + const id = astNode(node.id); + if (id?.type === 'Identifier') aliases.push([String(id.name), node.init]); + } + if (node.type === 'ObjectPattern') { + for (const property of node.properties as AstNode[]) { + if (property.type !== 'Property') continue; + const field = propertyName(property.key); + const value = astNode(property.value); + const binding = value?.type === 'AssignmentPattern' ? astNode(value.left) : value; + if (binding?.type !== 'Identifier') continue; + if (field === 'session') sessionBindings.add(String(binding.name)); + if (field === 'store' || field === 'sessionStore') + storeBindings.add(String(binding.name)); + } + } + }); + const inheritAliases = (): void => { + let added: boolean; + do { + added = false; + for (const [name, value] of aliases) { + if (!storeBindings.has(name) && isSessionStoreReceiver(value, storeBindings)) { + storeBindings.add(name); + added = true; + } + if ( + !sessionBindings.has(name) && + (isSessionRecord(value, sessionBindings) || isSessionRead(value, storeBindings)) + ) { + sessionBindings.add(name); + added = true; + } + } + } while (added); + }; + inheritAliases(); + visitAst(program, (node) => { + const callee = astNode(node.callee); + const args = node.arguments as unknown[] | undefined; + if ( + node.type !== 'CallExpression' || + callee?.type !== 'MemberExpression' || + memberName(callee) !== 'update' || + args?.length !== 2 || + !isSessionStoreReceiver(callee.object, storeBindings) + ) + return; + const patch = unwrapExpression(args[1]); + const objects = patchObjects(args[1]); + if (!objects) { + report(patch ?? node, '[patch-shape]'); return; } - const record = node as Record; - const target = writeTarget(record); - if (target && target.object !== undefined && isSessionBinding(target.object)) { - if (target.computed) { - writes.push({ file, line: lineOf(source, target.offset), field: '[computed]' }); - } else if (target.field !== undefined && declared.has(target.field)) { - writes.push({ file, line: lineOf(source, target.offset), field: target.field }); + if (patch?.type !== 'ObjectExpression') { + const binding = astNode((patch?.params as unknown[])?.[0]); + if (binding?.type === 'Identifier') sessionBindings.add(String(binding.name)); + visitAst(patch?.body, (inner) => { + const target = astNode(inner.callee); + if ( + inner.type === 'CallExpression' && + target?.type === 'MemberExpression' && + isSessionStoreReceiver(target.object, storeBindings) + ) + report(inner, '[reentrant-patch]'); + }); + } + for (const object of objects) { + patches.add(object); + for (const property of object.properties as AstNode[]) { + if ( + property.type !== 'Property' || + property.computed === true || + property.method === true || + property.kind !== 'init' + ) + report(property, '[patch-shape]'); + else report(property, propertyName(property.key) ?? '[patch-shape]'); } } - for (const key of Object.keys(record)) visit(record[key]); + }); + inheritAliases(); + const walk = (value: unknown, ancestors: readonly AstNode[]): void => { + if (Array.isArray(value)) { + for (const child of value) walk(child, ancestors); + return; + } + const node = astNode(value); + if (!node) return; + const target = writeTarget(node); + if (target?.object !== undefined && sessionBindings.has(target.object)) { + if (target.computed) report(node, '[computed]'); + else if (target.field !== undefined && declared.has(target.field)) + report(node, target.field); + } + if (node.type === 'ObjectExpression' && !patches.has(node)) { + const properties = node.properties as AstNode[]; + const copiesRecord = properties.some((property) => { + if (property.type !== 'SpreadElement') return false; + return isSessionRecord(property.argument, sessionBindings); + }); + if (copiesRecord) { + const enclosingFunction = [...ancestors] + .reverse() + .find((ancestor) => + ['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression'].includes( + String(ancestor.type), + ), + ); + const constructor = propertyName(enclosingFunction?.id); + const parent = ancestors.at(-1); + const publishedDraft = file.endsWith('/session-open-state.ts') + ? parent?.type === 'CallExpression' && + memberName(astNode(parent.callee) ?? {}) === 'publish' && + (parent.arguments as unknown[])[1] === node + : true; + const draft = SESSION_DRAFT_CONSTRUCTORS[file]; + if (!draft || constructor !== draft || !publishedDraft) { + report(node, '[whole-record-spread]'); + for (const property of properties) { + const field = propertyName(property.key); + if (property.type === 'Property' && declared.has(field ?? '')) + report(property, field!); + } + } + } + } + for (const child of Object.values(node)) walk(child, [...ancestors, node]); }; - visit(parsed.program); + walk(program, []); } - return writes.sort( (left, right) => left.file.localeCompare(right.file) || left.line - right.line, ); } export type SessionStateWritePressure = Readonly<{ - /** Declared fields that some daemon module writes directly. */ + /** Declared fields written by an owner through assignments, patches or record copies. */ writerOwnedFields: number; /** Distinct (field, writing module) pairs — what `SESSION_STATE_FIELD_OWNERS` claims. */ ownerFileClaims: number; }>; /** - * R10's measurement of R7 pressure: how many declared fields have a direct writer, and how many + * R10 measures how many declared fields have a writer, and how many * module claims that takes. Read from the tree rather than from the ownership table, so the same * function measures a merge-base tree whose table is not in scope. On a tree R7 accepts, both * numbers equal the table's own size. @@ -315,10 +484,11 @@ export function sessionStateWritePressure( ): SessionStateWritePressure { const declarationFile = sessionStateDeclarationFile(sources); if (!declarationFile) return { writerOwnedFields: 0, ownerFileClaims: 0 }; - const writes = findSessionStateWrites( - sources, - sessionStateFields(sources.get(declarationFile)!), - ).filter((write) => write.field !== '[computed]'); + const fields = sessionStateFields(sources.get(declarationFile)!); + const declared = new Set(fields); + const writes = findSessionStateWrites(sources, fields).filter((write) => + declared.has(write.field), + ); return { writerOwnedFields: new Set(writes.map((write) => write.field)).size, ownerFileClaims: new Set(writes.map((write) => `${write.field}\0${write.file}`)).size, diff --git a/src/__tests__/daemon-exit-wait.test.ts b/src/__tests__/daemon-exit-wait.test.ts index 2111bc2f3b..f2840761a1 100644 --- a/src/__tests__/daemon-exit-wait.test.ts +++ b/src/__tests__/daemon-exit-wait.test.ts @@ -57,6 +57,16 @@ afterEach(() => { vi.restoreAllMocks(); }); +test.each([0, -1, 1.5, 2_147_483_648, Number.MAX_SAFE_INTEGER])( + 'an invalid native pid %s cannot prove exit during recovery', + async (pid) => { + expect(await waitForDaemonExit({ pid, startTime: OURS }, { timeoutMs: 0 })).toEqual({ + exited: false, + elapsedMs: 0, + }); + }, +); + test('waitForDaemonExit reports a pid recycled mid-wait as exited, without burning the deadline', async () => { setTimeout(() => state.starts.set(PID, RECYCLED), 20); const wait = await waitForDaemonExit( diff --git a/src/__tests__/daemon-process-takeover.test.ts b/src/__tests__/daemon-process-takeover.test.ts index 78156ffc64..99292988f6 100644 --- a/src/__tests__/daemon-process-takeover.test.ts +++ b/src/__tests__/daemon-process-takeover.test.ts @@ -12,6 +12,19 @@ const TAKEOVER_TIMEOUTS = { termTimeoutMs: 5_000, killTimeoutMs: 2_000 }; const spawnedChildren: { child: ChildProcess; exited: Promise }[] = []; const spawnedRoots: string[] = []; +test.each([0, -1, 1.5, Number.NaN, '123', 2_147_483_648, Number.MAX_SAFE_INTEGER])( + 'an invalid daemon pid %s cannot prove exit', + async (pid) => { + assert.deepEqual( + await stopDaemonProcess( + { pid: pid as number, startTime: 'captured-birth' }, + { mode: 'force', termTimeoutMs: 0, killTimeoutMs: 0 }, + ), + { status: 'retained', reason: 'identity-unverified' }, + ); + }, +); + afterEach(async () => { for (const { child, exited } of spawnedChildren.splice(0)) { if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); diff --git a/src/__tests__/daemon-registration-owner.test.ts b/src/__tests__/daemon-registration-owner.test.ts index edb42abced..8af5ffc729 100644 --- a/src/__tests__/daemon-registration-owner.test.ts +++ b/src/__tests__/daemon-registration-owner.test.ts @@ -1,16 +1,33 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; +import path from 'node:path'; import { afterEach, test, vi } from 'vitest'; -import { readCurrentOwnerIdentity } from '@agent-device/host-kit/process'; +import { readCurrentOwnerIdentity, isProcessAlive } from '@agent-device/host-kit/process'; import { tryAcquireDaemonRegistration, stopAndRetireDaemon, recoverAbandonedDaemonRegistration, + createOwnedReplayStateDir, + DAEMON_STARTUP_EXIT_CODES, + launchDaemonProcess, + type OwnedReplayStateDir, } from '../daemon-registration-owner.ts'; import { resolveDaemonPaths, type DaemonPaths } from '../daemon-resolution.ts'; import { readRegisteredDaemonOwnership } from '../daemon-registration.ts'; import { readDaemonShutdownReport } from '../daemon-shutdown-report.ts'; import { mkdtempForTestSync } from './test-utils/tmp-dir.ts'; +import { + registeredDaemonFixtureArgs, + spawnRegisteredDaemonFixture, + finishRegisteredDaemonFixture, +} from './test-utils/registered-daemon-fixture.ts'; +import { spawnLegacyDaemonFixture } from './test-utils/legacy-daemon-fixture.ts'; +import { ensureDaemon, resolveClientSettings } from '../daemon-client/daemon-client-lifecycle.ts'; +import { inspectProcessLock } from '@agent-device/host-kit/file'; +import { AppError } from '@agent-device/kernel/errors'; +import { sleep } from '@agent-device/host-kit/retry'; +import { stopDaemonProcess } from '../daemon-process.ts'; +import { stopDaemon } from '../daemon/daemon-stop.ts'; const fields = { socketPort: 4210, @@ -305,3 +322,307 @@ test.skipIf(process.getuid?.() === 0)( } }, ); + +test('a forged private-directory capability cannot authorize even matching dead metadata removal', async () => { + const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-private-forgery-')); + replaceInfo(paths, deadIdentity.pid, deadIdentity.startTime); + const before = fs.readFileSync(paths.infoPath, 'utf8'); + const result = await stopAndRetireDaemon({ + paths, + observed: deadIdentity, + mode: 'force', + ownedStateDir: Object.freeze({ paths }) as OwnedReplayStateDir, + }); + assert.equal(result.status, 'retained'); + assert.equal(fs.readFileSync(paths.infoPath, 'utf8'), before); +}); + +test('private retirement closes startup admission, joins the actual child and never recreates a removed directory', async () => { + const ownedStateDir = createOwnedReplayStateDir(); + const paths = ownedStateDir.paths; + const args = registeredDaemonFixtureArgs(paths, fields); + const launch = launchDaemonProcess({ paths, args, serverMode: 'socket', ownedStateDir }); + let contender: ReturnType | undefined; + try { + assert.ok(launch.startTime); + await waitForFixtureFile(paths.infoPath); + contender = launchDaemonProcess({ paths, args, serverMode: 'socket', ownedStateDir }); + assert.equal((await contender.exited).exitCode, DAEMON_STARTUP_EXIT_CODES.busy); + const input = { + paths, + observed: { pid: launch.pid, startTime: launch.startTime }, + mode: 'graceful' as const, + ownedStateDir, + }; + const pending = stopAndRetireDaemon(input); + assert.throws( + () => launchDaemonProcess({ paths, args, serverMode: 'socket', ownedStateDir }), + (error: { details?: { reason?: string } }) => + error.details?.reason === 'daemon_startup_admission_closed', + ); + const result = await pending; + assert.equal(result.status, 'retired', JSON.stringify(result)); + if (result.status !== 'retired') assert.fail('retirement not confirmed'); + assert.equal(result.removedStateDir, true); + assert.equal((await launch.exited).exitCode, 0); + assert.equal(fs.existsSync(paths.baseDir), false); + assert.deepEqual(await stopAndRetireDaemon(input), result); + assert.equal(fs.existsSync(paths.baseDir), false); + } finally { + await finishPrivateTestDaemons(paths, launch, contender); + } +}); + +test('private retirement retains the directory while an earlier actual startup child is still paused', async () => { + const ownedStateDir = createOwnedReplayStateDir(); + const paths = ownedStateDir.paths; + const args = registeredDaemonFixtureArgs(paths, fields); + const entry = args[1]!; + const ready = `${paths.baseDir}/paused-startup.ready`; + fs.writeFileSync( + entry, + `import fs from 'node:fs'; fs.writeFileSync(${JSON.stringify(ready)}, 'ready'); setInterval(() => {}, 1000);`, + ); + const first = launchDaemonProcess({ paths, args, serverMode: 'socket', ownedStateDir }); + let second: ReturnType | undefined; + try { + await waitForFixtureFile(ready); + second = launchDaemonProcess({ + paths, + args: registeredDaemonFixtureArgs(paths, fields), + serverMode: 'socket', + ownedStateDir, + }); + await waitForFixtureFile(paths.infoPath); + const result = await stopAndRetireDaemon({ + paths, + observed: { pid: second.pid, startTime: second.startTime ?? null }, + mode: 'graceful', + ownedStateDir, + startupJoinTimeoutMs: 0, + }); + assert.equal(result.status, 'retained', JSON.stringify(result)); + if (result.status !== 'retained') assert.fail('private state unexpectedly retired'); + assert.equal(result.reason, 'startup-unconfirmed'); + assert.equal(result.termination?.status, 'exited'); + assert.equal(fs.existsSync(paths.baseDir), true); + assert.equal(isProcessAlive(first.pid), true); + assert.equal((await second.exited).exitCode, 0); + } finally { + await finishPrivateTestDaemons(paths, first, second); + } +}); + +for (const contents of [ + '{broken', + '{"owner":"default","expiresAt":1e400}', + '{"owner":"default","expiresAt":-1e400}', + ...[false, null, 0, {}].map((commitFailure) => + JSON.stringify({ owner: 'default', expiresAt: Date.now() + 60_000, commitFailure }), + ), +]) { + test(`malformed repair evidence (${contents}) retains private state after the actual child has exited`, async () => { + const ownedStateDir = createOwnedReplayStateDir(); + const paths = ownedStateDir.paths; + const sessionDir = `${paths.sessionsDir}/default`; + fs.mkdirSync(sessionDir, { recursive: true }); + const evidencePath = `${sessionDir}/repair-tombstone.json`; + fs.writeFileSync(evidencePath, contents); + const launch = launchDaemonProcess({ + paths, + args: registeredDaemonFixtureArgs(paths, fields), + serverMode: 'socket', + ownedStateDir, + }); + try { + await waitForFixtureFile(paths.infoPath); + const result = await stopAndRetireDaemon({ + paths, + observed: { pid: launch.pid, startTime: launch.startTime ?? null }, + mode: 'graceful', + ownedStateDir, + }); + assert.equal(result.status, 'retained', JSON.stringify(result)); + if (result.status !== 'retained') assert.fail('repair evidence unexpectedly discarded'); + assert.equal(result.termination?.status, 'exited'); + assert.equal(result.error?.details?.reason, 'repair_evidence_invalid'); + assert.equal(fs.readFileSync(evidencePath, 'utf8'), contents); + await launch.exited; + } finally { + await finishPrivateTestDaemons(paths, launch); + } + }); +} + +async function waitForFixtureFile(filePath: string): Promise { + const deadline = Date.now() + 2_000; + while (!fs.existsSync(filePath) && Date.now() < deadline) await sleep(20); + assert.equal(fs.existsSync(filePath), true); +} + +async function finishPrivateTestDaemons( + paths: DaemonPaths, + ...launches: (ReturnType | undefined)[] +): Promise { + for (const launch of launches) { + if (!launch) continue; + const termination = await stopDaemonProcess( + { pid: launch.pid, startTime: launch.startTime ?? null }, + { mode: 'force', termTimeoutMs: 0, killTimeoutMs: 2_000 }, + ); + assert.notEqual(termination.status, 'retained', JSON.stringify(termination)); + await launch.exited; + } + fs.rmSync(paths.baseDir, { recursive: true, force: true }); +} + +async function waitForCutoverFixture(ready: () => boolean): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + if (ready()) return; + await sleep(10); + } + assert.fail('cutover fixture did not reach its barrier'); +} + +function legacyDisposition(paths: DaemonPaths): boolean | undefined { + try { + const parsed = JSON.parse( + fs.readFileSync(path.join(paths.baseDir, 'legacy-disposition.json'), 'utf8'), + ) as { acquired?: unknown }; + return typeof parsed.acquired === 'boolean' ? parsed.acquired : undefined; + } catch { + return undefined; + } +} + +test('cutover refuses an already-running legacy daemon before signaling or changing registration', async () => { + const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-old-daemon-')); + const legacy = spawnLegacyDaemonFixture(paths); + try { + await waitForCutoverFixture(() => fs.existsSync(paths.infoPath)); + const metadata = fs.readFileSync(paths.infoPath, 'utf8'); + const lock = fs.readFileSync(paths.lockPath, 'utf8'); + const contender = spawnRegisteredDaemonFixture(paths, fields, undefined); + let disposition: Awaited | undefined; + void contender.exited.then((result) => { + disposition = result; + }); + await waitForCutoverFixture( + () => Boolean(disposition) || fs.existsSync(path.join(paths.baseDir, 'registration-held')), + ); + assert.ok(disposition, 'a new daemon must refuse an occupied legacy file'); + assert.equal(disposition.exitCode, DAEMON_STARTUP_EXIT_CODES.unproven); + await assert.rejects( + ensureDaemon( + resolveClientSettings({ + session: 'default', + command: 'devices', + positionals: [], + flags: { stateDir: paths.baseDir }, + }), + ), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.reason, 'daemon_registration_unproven'); + return true; + }, + ); + assert.equal(process.kill(legacy.pid, 0), true); + assert.equal(fs.readFileSync(paths.infoPath, 'utf8'), metadata); + assert.equal(fs.readFileSync(paths.lockPath, 'utf8'), lock); + } finally { + await legacy.stop(); + await finishRegisteredDaemonFixture(paths.baseDir); + } +}); + +test('a legacy contender cannot unlink a hardened owner while it delays metadata publication', async () => { + const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-new-daemon-')); + const deferred = path.join(paths.baseDir, 'defer-publication'); + fs.writeFileSync(deferred, 'wait'); + const current = spawnRegisteredDaemonFixture(paths, fields, undefined); + let legacy: ReturnType | undefined; + try { + await waitForCutoverFixture(() => fs.existsSync(path.join(paths.baseDir, 'registration-held'))); + legacy = spawnLegacyDaemonFixture(paths); + await waitForCutoverFixture(() => legacyDisposition(paths) !== undefined); + assert.equal(legacyDisposition(paths), false); + await legacy.exited; + const claim = inspectProcessLock(paths.lockPath); + assert.equal(claim.state, 'held'); + if (claim.state !== 'held') throw new Error('current owner lost its claim'); + assert.equal(claim.owner.pid, current.pid); + assert.equal(process.kill(current.pid, 0), true); + assert.equal(fs.existsSync(paths.infoPath), false); + fs.unlinkSync(deferred); + await waitForCutoverFixture(() => fs.existsSync(paths.infoPath)); + assert.equal(JSON.parse(fs.readFileSync(paths.infoPath, 'utf8')).pid, current.pid); + } finally { + await legacy?.stop(); + await finishRegisteredDaemonFixture(paths.baseDir); + } +}); + +test('concurrent old and new daemon startup has one owner at the shared lock path', async () => { + const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-cutover-race-')); + const barrier = path.join(paths.baseDir, 'start'); + const legacy = spawnLegacyDaemonFixture(paths, barrier); + const current = spawnRegisteredDaemonFixture(paths, fields, undefined, barrier); + let currentExited = false; + void current.exited.then(() => { + currentExited = true; + }); + try { + await waitForCutoverFixture( + () => + fs.existsSync(`${barrier}.ready-${legacy.pid}`) && + fs.existsSync(`${barrier}.ready-${current.pid}`), + ); + fs.writeFileSync(barrier, 'start'); + await waitForCutoverFixture( + () => + legacyDisposition(paths) !== undefined && + (currentExited || fs.existsSync(path.join(paths.baseDir, 'registration-held'))), + ); + const oldAcquired = legacyDisposition(paths); + const claim = inspectProcessLock(paths.lockPath); + const newAcquired = fs.existsSync(path.join(paths.baseDir, 'registration-held')); + assert.equal(Number(oldAcquired) + Number(newAcquired), 1); + if (newAcquired) { + assert.ok(claim.state === 'held'); + assert.equal(claim.owner.pid, current.pid); + } + if (oldAcquired) + assert.equal((await current.exited).exitCode, DAEMON_STARTUP_EXIT_CODES.unproven); + else await legacy.exited; + await waitForCutoverFixture(() => fs.existsSync(paths.infoPath)); + assert.equal( + JSON.parse(fs.readFileSync(paths.infoPath, 'utf8')).pid, + newAcquired ? current.pid : legacy.pid, + ); + } finally { + await legacy.stop(); + await finishRegisteredDaemonFixture(paths.baseDir); + } +}); + +for (const mode of ['graceful', 'forced'] as const) { + test(`manual ${mode} stop awaits actual child exit and protected registration retirement`, async () => { + const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-manual-stop-')); + if (mode === 'forced') fs.writeFileSync(path.join(paths.baseDir, 'ignore-sigterm'), 'hold'); + const child = spawnRegisteredDaemonFixture(paths, fields, undefined); + try { + await waitForFixtureFile(paths.infoPath); + const result = await stopDaemon({ paths, graceTimeoutMs: 30, killTimeoutMs: 1_000 }); + assert.equal(result.stopped, true); + assert.equal(result.mode, mode); + assert.equal(result.cleanupConfidence, mode === 'forced' ? 'unknown' : 'known'); + await child.exited; + assert.equal(fs.existsSync(paths.infoPath), false); + assert.equal(fs.existsSync(paths.lockPath), false); + assert.equal(fs.existsSync(paths.baseDir), true); + } finally { + await finishRegisteredDaemonFixture(paths.baseDir); + } + }); +} diff --git a/src/__tests__/test-utils/daemon-http-fixture.ts b/src/__tests__/test-utils/daemon-http-fixture.ts index 4712a049c6..485bba4e7e 100644 --- a/src/__tests__/test-utils/daemon-http-fixture.ts +++ b/src/__tests__/test-utils/daemon-http-fixture.ts @@ -16,6 +16,7 @@ export type HttpDaemonFixture = { export async function startHttpDaemonFixture( responseData: Record, + options: { ready?: () => boolean } = {}, ): Promise { const seenPaths: string[] = []; const rpcRequests: Record[] = []; @@ -24,7 +25,7 @@ export async function startHttpDaemonFixture( seenPaths.push(`${req.method ?? 'GET'} ${url.pathname}`); if (req.method === 'GET' && url.pathname === '/health') { - res.writeHead(200); + res.writeHead(options.ready?.() === false ? 503 : 200); res.end('ok'); return; } diff --git a/src/__tests__/test-utils/legacy-daemon-fixture.ts b/src/__tests__/test-utils/legacy-daemon-fixture.ts new file mode 100644 index 0000000000..b5b40ff06a --- /dev/null +++ b/src/__tests__/test-utils/legacy-daemon-fixture.ts @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { runCmdDetachedMonitored } from '@agent-device/host-kit/command'; +import { readProcessStartTime } from '@agent-device/host-kit/process'; +import { stopDaemonProcess } from '../../daemon-process.ts'; +import type { DaemonPaths } from '../../daemon-resolution.ts'; + +// c237027737: server-lifecycle.ts readLockInfo/acquireDaemonLock/releaseDaemonLock. +const legacyLockProtocol = ` +function readLockInfo(lockPath) { + if (!fs.existsSync(lockPath)) return null; + try { + const parsed = JSON.parse(fs.readFileSync(lockPath, 'utf8')); + if (!Number.isInteger(parsed.pid) || parsed.pid <= 0) return null; + return parsed; + } catch { + return null; + } +} +function acquireDaemonLock(baseDir, lockPath, lockData) { + if (!fs.existsSync(baseDir)) fs.mkdirSync(baseDir, { recursive: true }); + const payload = JSON.stringify(lockData, null, 2); + const tryWriteLock = () => { + try { + fs.writeFileSync(lockPath, payload, { flag: 'wx', mode: 0o600 }); + return true; + } catch (error) { + if (error.code === 'EEXIST') return false; + throw error; + } + }; + if (tryWriteLock()) return true; + const existing = readLockInfo(lockPath); + if (existing?.pid && existing.pid !== process.pid && + isAgentDeviceDaemonProcess(existing.pid, existing.processStartTime)) return false; + try { fs.unlinkSync(lockPath); } catch {} + return tryWriteLock(); +} +function releaseDaemonLock(lockPath) { + const existing = readLockInfo(lockPath); + if (existing && existing.pid !== process.pid) return; + try { if (fs.existsSync(lockPath)) fs.unlinkSync(lockPath); } catch {} +} +`; + +export function spawnLegacyDaemonFixture(paths: DaemonPaths, acquisitionBarrier?: string) { + const codeDir = path.join(paths.baseDir, 'legacy'); + const entry = path.join(codeDir, 'dist', 'src', 'internal', 'daemon.js'); + fs.mkdirSync(path.dirname(entry), { recursive: true }); + fs.writeFileSync(path.join(codeDir, 'package.json'), '{"type":"module"}'); + const processUrl = new URL('../../daemon-process.ts', import.meta.url).href; + const hostProcessUrl = new URL('../../../packages/host-kit/src/process.ts', import.meta.url).href; + fs.writeFileSync( + entry, + ` +import fs from 'node:fs'; +import { isAgentDeviceDaemonProcess } from ${JSON.stringify(processUrl)}; +import { readProcessStartTime } from ${JSON.stringify(hostProcessUrl)}; +${legacyLockProtocol} +const paths = ${JSON.stringify(paths)}; +const barrier = ${JSON.stringify(acquisitionBarrier)}; +if (barrier) { + fs.writeFileSync(barrier + '.ready-' + process.pid, 'ready'); + while (!fs.existsSync(barrier)) await new Promise(resolve => setTimeout(resolve, 10)); +} +const identity = { pid: process.pid, processStartTime: readProcessStartTime(process.pid) }; +const acquired = acquireDaemonLock(paths.baseDir, paths.lockPath, { + ...identity, version: '0.21.20', startedAt: Date.now(), +}); +fs.writeFileSync(paths.baseDir + '/legacy-disposition.json', JSON.stringify({ acquired })); +if (!acquired) process.exit(0); +fs.writeFileSync(paths.infoPath, JSON.stringify({ ...identity, port: 4210, token: 'legacy-token', version: '0.21.20' })); +process.on('SIGTERM', () => { releaseDaemonLock(paths.lockPath); process.exit(0); }); +setInterval(() => {}, 1000); +`, + ); + const child = runCmdDetachedMonitored(process.execPath, ['--experimental-strip-types', entry]); + const startTime = readProcessStartTime(child.pid); + return { + pid: child.pid, + exited: child.exited, + async stop() { + const result = await stopDaemonProcess( + { pid: child.pid, startTime }, + { mode: 'force', termTimeoutMs: 0, killTimeoutMs: 2_000 }, + ); + assert.notEqual(result.status, 'retained', JSON.stringify(result)); + await child.exited; + }, + }; +} diff --git a/src/__tests__/test-utils/registered-daemon-fixture.test.ts b/src/__tests__/test-utils/registered-daemon-fixture.test.ts new file mode 100644 index 0000000000..e4e265f4af --- /dev/null +++ b/src/__tests__/test-utils/registered-daemon-fixture.test.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { test } from 'vitest'; +import { resolveDaemonPaths } from '../../daemon-resolution.ts'; +import { stopDaemonProcess } from '../../daemon-process.ts'; +import { mkdtempForTestSync } from './tmp-dir.ts'; +import { + spawnRegisteredDaemonFixture, + waitForRegisteredDaemonFixture, + finishRegisteredDaemonFixture, +} from './registered-daemon-fixture.ts'; + +test('a joined fixture exit refuses its remaining registration metadata', async () => { + const paths = resolveDaemonPaths(mkdtempForTestSync('daemon-fixture-exited-publication-')); + const child = spawnRegisteredDaemonFixture( + paths, + { + httpPort: 4210, + token: 'fixture', + version: 'test', + codeOrigin: 'checkout', + codeSignature: 'fixture', + }, + undefined, + ); + try { + const observed = await waitForRegisteredDaemonFixture(paths, child); + assert.equal( + ( + await stopDaemonProcess( + { pid: child.pid, startTime: observed.processStartTime ?? null }, + { mode: 'force', termTimeoutMs: 0, killTimeoutMs: 1_000 }, + ) + ).status, + 'exited', + ); + await child.exited; + assert.equal(fs.existsSync(paths.infoPath), true); + await assert.rejects(waitForRegisteredDaemonFixture(paths, child), /exited before publication/); + } finally { + await finishRegisteredDaemonFixture(paths.baseDir); + } +}); diff --git a/src/__tests__/test-utils/registered-daemon-fixture.ts b/src/__tests__/test-utils/registered-daemon-fixture.ts new file mode 100644 index 0000000000..9131617410 --- /dev/null +++ b/src/__tests__/test-utils/registered-daemon-fixture.ts @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { vi } from 'vitest'; +import type { runCmdDetachedMonitored, ExecDetachedExit } from '@agent-device/host-kit/command'; +import { readDaemonInfo, type DaemonInfo } from '../../daemon-client/daemon-client-metadata.ts'; +import { readProcessStartTime } from '@agent-device/host-kit/process'; +import { stopDaemonProcess } from '../../daemon-process.ts'; +import type { DaemonPaths } from '../../daemon-resolution.ts'; +import type { DaemonRegistrationFields } from '../../daemon-registration-owner.ts'; + +const actualCommand = await vi.importActual( + '@agent-device/host-kit/command', +); +const children = new Map< + string, + Array<{ launch: ReturnType; startTime: string | null }> +>(); + +/** A real registration owner, advertising the caller's HTTP fixture and joining before deletion. */ +export function registeredDaemonFixtureArgs( + paths: DaemonPaths, + fields: DaemonRegistrationFields, + acquisitionBarrier?: string, +): string[] { + const entry = path.join(paths.baseDir, 'dist', 'src', 'internal', 'daemon.js'); + fs.mkdirSync(path.dirname(entry), { recursive: true }); + fs.writeFileSync(path.join(paths.baseDir, 'package.json'), '{"type":"module"}'); + const registrationUrl = new URL('../../daemon-registration-owner.ts', import.meta.url).href; + fs.writeFileSync( + entry, + `import fs from 'node:fs'; +import path from 'node:path'; +import { DAEMON_STARTUP_EXIT_CODES, tryAcquireDaemonRegistration } from ${JSON.stringify(registrationUrl)}; +const paths = ${JSON.stringify(paths)}; +const barrier = ${JSON.stringify(acquisitionBarrier)}; +if (barrier) { + fs.writeFileSync(barrier + '.ready-' + process.pid, 'ready'); + while (!fs.existsSync(barrier)) await new Promise(resolve => setTimeout(resolve, 10)); +} +const acquired = await tryAcquireDaemonRegistration(paths); +if (acquired.status !== 'acquired') process.exit(DAEMON_STARTUP_EXIT_CODES[acquired.status]); +process.on('SIGTERM', async () => { + if (fs.existsSync(path.join(paths.baseDir, 'ignore-sigterm'))) return; + const deferred = path.join(paths.baseDir, 'repair-on-shutdown.json'); + if (fs.existsSync(deferred)) { + const dir = path.join(paths.sessionsDir, 'default'); + fs.mkdirSync(dir, { recursive: true }); + fs.copyFileSync(deferred, path.join(dir, 'repair-tombstone.json')); + } + await acquired.owner.finish(); + process.exit(0); +}); +fs.writeFileSync(path.join(paths.baseDir, 'registration-held'), 'ready'); +while (fs.existsSync(path.join(paths.baseDir, 'defer-publication'))) await new Promise(resolve => setTimeout(resolve, 10)); +acquired.owner.publish(${JSON.stringify(fields)}); +setInterval(() => {}, 1000); +`, + ); + return ['--experimental-strip-types', entry]; +} + +export function spawnRegisteredDaemonFixture( + paths: DaemonPaths, + fields: DaemonRegistrationFields, + options: Parameters[2], + acquisitionBarrier?: string, +): ReturnType { + const child = actualCommand.runCmdDetachedMonitored( + process.execPath, + registeredDaemonFixtureArgs(paths, fields, acquisitionBarrier), + options, + ); + const owned = children.get(paths.baseDir) ?? []; + owned.push({ launch: child, startTime: readProcessStartTime(child.pid) }); + children.set(paths.baseDir, owned); + return child; +} + +export async function waitForRegisteredDaemonFixture( + paths: DaemonPaths, + child: ReturnType, +): Promise { + let exit: ExecDetachedExit | undefined; + void child.exited.then((result) => { + exit = result; + }); + await Promise.resolve(); + for (let attempt = 0; attempt < 400; attempt += 1) { + if (exit) + throw new Error(`Registered child exited before publication: ${JSON.stringify(exit)}`); + const info = readDaemonInfo(paths.infoPath); + if (info?.pid === child.pid) return info; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`Registered child ${child.pid} did not publish ${paths.infoPath} within 4s`); +} + +export async function finishRegisteredDaemonFixture(stateDir: string): Promise { + for (const owned of children.get(stateDir) ?? []) { + const child = owned.launch; + const termination = await stopDaemonProcess( + { pid: child.pid, startTime: owned.startTime }, + { mode: 'force', termTimeoutMs: 0, killTimeoutMs: 2_000 }, + ); + assert.notEqual(termination.status, 'retained', JSON.stringify(termination)); + await child.exited; + } + children.delete(stateDir); + fs.rmSync(stateDir, { recursive: true, force: true }); +} + +export async function finishRegisteredDaemonFixtures(): Promise { + for (const stateDir of children.keys()) await finishRegisteredDaemonFixture(stateDir); +} diff --git a/src/__tests__/test-utils/store-factory.ts b/src/__tests__/test-utils/store-factory.ts index 6fccc173af..c646a9b861 100644 --- a/src/__tests__/test-utils/store-factory.ts +++ b/src/__tests__/test-utils/store-factory.ts @@ -1,8 +1,24 @@ import path from 'node:path'; import { SessionStore } from '../../daemon/session-store.ts'; import { mkdtempForTestSync } from './tmp-dir.ts'; +import type { SessionRef, SessionState } from '../../daemon/session-state.ts'; export function makeSessionStore(prefix = 'agent-device-test-'): SessionStore { const tempRoot = mkdtempForTestSync(prefix); return new SessionStore(path.join(tempRoot, 'sessions')); } + +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-client/__tests__/daemon-client-lifecycle.test.ts b/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts index 91f4da210a..9c67e88a31 100644 --- a/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts +++ b/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts @@ -6,6 +6,12 @@ import net from 'node:net'; import path from 'node:path'; import { afterEach, test, vi } from 'vitest'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { + spawnRegisteredDaemonFixture, + waitForRegisteredDaemonFixture, + finishRegisteredDaemonFixture, + finishRegisteredDaemonFixtures, +} from '../../__tests__/test-utils/registered-daemon-fixture.ts'; vi.mock('@agent-device/host-kit/command', async (importOriginal) => ({ ...(await importOriginal()), @@ -22,6 +28,7 @@ import { resolveDaemonPaths, type DaemonPaths } from '../../daemon-resolution.ts import { sendToDaemon, type DaemonRequest, type DaemonResponse } from '../daemon-client.ts'; import { attachActiveSessionAddressHint } from '../daemon-client-lifecycle.ts'; import { sendRequest } from '../daemon-client-transport.ts'; +import type { DaemonRetirementResult } from '../../daemon-registration-owner.ts'; import { closeLoopbackServer, listenOnLoopback, @@ -34,6 +41,7 @@ import { currentDaemonCodeSignature, } from '../../__tests__/test-utils/daemon-http-fixture.ts'; import { AppError } from '@agent-device/kernel/errors'; +import { tryAcquireProcessLock, inspectProcessLock } from '@agent-device/host-kit/file'; import { runCmdDetachedMonitored, runCmdSync } from '@agent-device/host-kit/command'; import { shellQuoteIfNeeded } from '@agent-device/kernel/device-shell'; import { readProcessStartTime } from '@agent-device/host-kit/process'; @@ -51,14 +59,19 @@ type DaemonInfoFixture = { processStartTime?: string; }; +const actualRetry = await vi.importActual( + '@agent-device/host-kit/retry', +); const mockRunCmdDetached = vi.mocked(runCmdDetachedMonitored); const mockRunCmdSync = vi.mocked(runCmdSync); const mockSleep = vi.mocked(sleep); -afterEach(() => { +afterEach(async () => { + await finishRegisteredDaemonFixtures(); mockRunCmdDetached.mockReset(); mockRunCmdSync.mockClear(); - mockSleep.mockClear(); + mockSleep.mockReset(); + mockSleep.mockImplementation(async () => {}); vi.unstubAllEnvs(); }); @@ -147,16 +160,22 @@ function installSpawnedHttpDaemonAtOwnedStateDir( httpPort: number, onStateDir: (stateDir: string) => void, ): void { + mockSleep.mockImplementation(actualRetry.sleep); mockRunCmdDetached.mockImplementation((_command, _args, options) => { const ownedStateDir = String(options?.env?.AGENT_DEVICE_STATE_DIR); onStateDir(ownedStateDir); const ownedPaths = resolveDaemonPaths(ownedStateDir); - writeDaemonInfo(ownedPaths, { httpPort, transport: 'http' }); - writeDaemonLock(ownedPaths, { - pid: process.pid, - processStartTime: readProcessStartTime(process.pid) ?? undefined, - }); - return { pid: process.pid, exited: new Promise(() => {}) }; + return spawnRegisteredDaemonFixture( + ownedPaths, + { + httpPort, + token: 'local-secret', + version: readVersion(), + codeOrigin: 'checkout', + codeSignature: currentDaemonCodeSignature(), + }, + options, + ); }); } @@ -192,14 +211,20 @@ async function startHangingHttpDaemonFixture(): Promise { } function installSpawnedHttpDaemon(paths: DaemonPaths, httpPort: number): void { + mockSleep.mockImplementation(actualRetry.sleep); mockRunCmdDetached.mockImplementation((_command, _args, options) => { assert.equal(options?.env?.AGENT_DEVICE_STATE_DIR, paths.baseDir); - writeDaemonInfo(paths, { httpPort, transport: 'http' }); - writeDaemonLock(paths, { - pid: process.pid, - processStartTime: readProcessStartTime(process.pid) ?? undefined, - }); - return { pid: process.pid, exited: new Promise(() => {}) }; + return spawnRegisteredDaemonFixture( + paths, + { + httpPort, + token: 'local-secret', + version: readVersion(), + codeOrigin: 'checkout', + codeSignature: currentDaemonCodeSignature(), + }, + options, + ); }); } @@ -298,7 +323,7 @@ function mockSocketErrorAfterWrite(failingPort: number): { }; } -test('sendToDaemon retries daemon spawn failures and cleans partial metadata on terminal failure', async () => { +test('sendToDaemon retains unknown metadata after a spawn failure', async () => { const stateDir = makeTempStateDir('agent-device-daemon-spawn-retry-'); const paths = resolveDaemonPaths(stateDir); vi.stubEnv('AGENT_DEVICE_STATE_DIR', stateDir); @@ -329,27 +354,19 @@ test('sendToDaemon retries daemon spawn failures and cleans partial metadata on assert.ok(thrown instanceof AppError); assert.equal(thrown.message, 'Failed to start daemon'); - assert.equal(thrown.details?.startError, 'spawn failed 2'); - assert.equal(thrown.details?.startupAttempts, 2); - const cleanupResults = thrown.details?.cleanupResults; - assert.ok(Array.isArray(cleanupResults)); - assert.deepEqual( - cleanupResults.map((result) => ({ - reason: result.reason, - removedInfo: result.removedInfo, - removedLock: result.removedLock, - })), - [ - { reason: 'start_error', removedInfo: true, removedLock: true }, - { reason: 'start_error', removedInfo: true, removedLock: true }, - ], - ); - assert.equal(attempts, 2); - assert.equal(mockSleep.mock.calls[0]?.[0], 150); - assert.equal(fs.existsSync(paths.infoPath), false); - assert.equal(fs.existsSync(paths.lockPath), false); + assert.equal(fs.readFileSync(paths.infoPath, 'utf8'), '{"partial":true}\n'); + assert.equal(fs.readFileSync(paths.lockPath, 'utf8'), 'not-json\n'); + assert.equal(thrown.details?.startError, 'spawn failed 1'); + assert.equal(thrown.details?.startupAttempts, 1); + const results = thrown.details?.cleanupResults as Array<{ + status: string; + removedInfo: boolean; + }>; + assert.equal(results[0]?.status, 'retained'); + assert.equal(results[0]?.removedInfo, false); + assert.equal(attempts, 1); } finally { - fs.rmSync(stateDir, { recursive: true, force: true }); + await finishRegisteredDaemonFixture(stateDir); } }); @@ -394,11 +411,11 @@ test('sendToDaemon reports early daemon exit with log tail and startup paths', a assert.match(String(thrown.details?.daemonLogTail), /early daemon failure 2/); assert.equal(attempts, 2); } finally { - fs.rmSync(stateDir, { recursive: true, force: true }); + await finishRegisteredDaemonFixture(stateDir); } }); -test('sendToDaemon removes stale daemon lock before spawning a fresh daemon', async (t) => { +test('daemon acquisition reclaims a proven reused owner before publication', async (t) => { if (!(await supportsLoopbackBind())) { t.skip('loopback listeners are not permitted in this environment'); return; @@ -408,10 +425,11 @@ test('sendToDaemon removes stale daemon lock before spawning a fresh daemon', as const paths = resolveDaemonPaths(stateDir); const daemon = await startHttpDaemonFixture({ via: 'fresh-daemon' }); vi.stubEnv('AGENT_DEVICE_STATE_DIR', stateDir); - writeDaemonLock(paths, { - pid: process.pid, - processStartTime: 'stale-start-time', + const stale = tryAcquireProcessLock({ + lockDirPath: paths.lockPath, + owner: { pid: process.pid, startTime: 'stale-start-time', acquiredAtMs: Date.now() }, }); + assert.equal(stale.status, 'acquired'); installSpawnedHttpDaemon(paths, daemon.port); try { @@ -423,18 +441,16 @@ test('sendToDaemon removes stale daemon lock before spawning a fresh daemon', as meta: { requestId: 'req-stale-lock' }, }); - const freshLock = JSON.parse(fs.readFileSync(paths.lockPath, 'utf8')) as { - pid?: number; - processStartTime?: string; - }; + const freshLock = inspectProcessLock(paths.lockPath); assert.deepEqual(response, { ok: true, data: { via: 'fresh-daemon' } }); assert.equal(mockRunCmdDetached.mock.calls.length, 1); - assert.equal(freshLock.pid, process.pid); - assert.notEqual(freshLock.processStartTime, 'stale-start-time'); + assert.equal(freshLock.state, 'held'); + if (freshLock.state === 'held') assert.notEqual(freshLock.owner.startTime, 'stale-start-time'); assert.deepEqual(daemon.seenPaths, ['GET /health', 'POST /rpc']); } finally { + if (stale.status === 'acquired') await stale.acquisition.release(); await closeLoopbackServer(daemon.server); - fs.rmSync(stateDir, { recursive: true, force: true }); + await finishRegisteredDaemonFixture(stateDir); } }); @@ -502,7 +518,7 @@ test('sendToDaemon does not reuse reachable daemon metadata with mismatched vers stderrCapture.restore(); await closeLoopbackServer(staleDaemon.server); await closeLoopbackServer(freshDaemon.server); - fs.rmSync(stateDir, { recursive: true, force: true }); + await finishRegisteredDaemonFixture(stateDir); vi.unstubAllEnvs(); } } @@ -516,7 +532,6 @@ test('sendToDaemon prints a takeover notice before replacing an unreachable daem const stateDir = makeTempStateDir('agent-device-daemon-unreachable-takeover-'); const paths = resolveDaemonPaths(stateDir); - // Bind fresh BEFORE freeing the port below: a later bind can reclaim it and skip the takeover. const freshDaemon = await startHttpDaemonFixture({ via: 'fresh-daemon' }); const unreachable = await startHttpDaemonFixture({ via: 'unused' }); await closeLoopbackServer(unreachable.server); @@ -546,7 +561,7 @@ test('sendToDaemon prints a takeover notice before replacing an unreachable daem } finally { stderrCapture.restore(); await closeLoopbackServer(freshDaemon.server); - fs.rmSync(stateDir, { recursive: true, force: true }); + await finishRegisteredDaemonFixture(stateDir); } }); @@ -587,7 +602,7 @@ test('sendToDaemon replaces socket-only daemon metadata when HTTP transport is r } finally { stderrCapture.restore(); await closeLoopbackServer(freshDaemon.server); - fs.rmSync(stateDir, { recursive: true, force: true }); + await finishRegisteredDaemonFixture(stateDir); } }); @@ -602,12 +617,18 @@ test('sendRequest timeout cleanup uses resolved daemon paths instead of request const daemonPaths = resolveDaemonPaths(daemonStateDir); const requestFlagPaths = resolveDaemonPaths(requestFlagStateDir); const daemon = await startHangingHttpDaemonFixture(); - writeDaemonInfo(daemonPaths, { - httpPort: daemon.port, - transport: 'http', - pid: 999_999, - }); - writeDaemonLock(daemonPaths, { pid: 999_999 }); + mockSleep.mockImplementation(actualRetry.sleep); + const child = spawnRegisteredDaemonFixture( + daemonPaths, + { + httpPort: daemon.port, + token: 'local-secret', + version: readVersion(), + codeOrigin: 'checkout', + codeSignature: currentDaemonCodeSignature(), + }, + undefined, + ); writeDaemonInfo(requestFlagPaths, { httpPort: daemon.port, transport: 'http', @@ -625,26 +646,21 @@ test('sendRequest timeout cleanup uses resolved daemon paths instead of request }; try { + const info = await waitForRegisteredDaemonFixture(daemonPaths, child); let thrown: unknown; try { - await sendRequest( - { - token: 'local-secret', - pid: 999_999, - httpPort: daemon.port, - transport: 'http', - }, - request, - 'http', - daemonPaths, - 50, - ); + await sendRequest(info, request, 'http', daemonPaths, 50); } catch (error) { thrown = error; } assert.ok(thrown instanceof AppError); assert.equal(thrown.message, 'Daemon request timed out'); + assert.equal( + (thrown.details?.retirement as DaemonRetirementResult | undefined)?.status, + 'retired', + ); + await child.exited; assert.deepEqual(daemon.seenPaths, ['POST /rpc']); assert.equal(fs.existsSync(daemonPaths.infoPath), false); assert.equal(fs.existsSync(daemonPaths.lockPath), false); @@ -652,7 +668,7 @@ test('sendRequest timeout cleanup uses resolved daemon paths instead of request assert.equal(fs.existsSync(requestFlagPaths.lockPath), true); } finally { await closeLoopbackServer(daemon.server); - fs.rmSync(daemonStateDir, { recursive: true, force: true }); + await finishRegisteredDaemonFixture(daemonStateDir); fs.rmSync(requestFlagStateDir, { recursive: true, force: true }); } }); @@ -690,7 +706,7 @@ test('sendToDaemon falls back from failed socket transport to HTTP using daemon } finally { socketFailures.restore(); await closeLoopbackServer(daemon.server); - fs.rmSync(stateDir, { recursive: true, force: true }); + await finishRegisteredDaemonFixture(stateDir); } }); @@ -734,16 +750,10 @@ test('sendToDaemon does not replay over HTTP after the socket request is written } finally { socket.restore(); await closeLoopbackServer(daemon.server); - fs.rmSync(stateDir, { recursive: true, force: true }); + await finishRegisteredDaemonFixture(stateDir); } }); -// --- ADR 0012 decision 6, R7 (Fix 1, C1): a repair-armed `replay --save-script` -// that comes back as a HELD divergence (the daemon's `resume.repairSessionHeld` -// signal) must keep its owning (owned/ephemeral) daemon alive and addressable. -// The keep-alive keys on that signal — the REPAIR-ARMED condition — NOT on -// `resume.allowed`, which reports only plan-resumability. --- - function heldDivergenceError( resume: Record = { allowed: true, from: 3, planDigest: 'digest-abc' }, ): Record { @@ -805,15 +815,13 @@ test('sendToDaemon keeps an owned ephemeral daemon alive and hints its --state-d assert.match(String(response.error.hint), /--state-dir/); assert.ok(String(response.error.hint).includes(ownedStateDir)); - // The daemon was NOT torn down: metadata and the owned state dir itself - // are still on disk, addressable by a follow-up command's --state-dir. const ownedPaths = resolveDaemonPaths(ownedStateDir); assert.equal(fs.existsSync(ownedPaths.infoPath), true); assert.equal(fs.existsSync(ownedPaths.lockPath), true); assert.equal(fs.existsSync(ownedStateDir), true); } finally { await closeLoopbackServer(daemon.server); - if (ownedStateDir) fs.rmSync(ownedStateDir, { recursive: true, force: true }); + if (ownedStateDir) await finishRegisteredDaemonFixture(ownedStateDir); } }); @@ -823,8 +831,6 @@ test('C1: keep-alive keys on repairSessionHeld, NOT resume.allowed — a HELD di return; } - // resume.allowed:false (plan not resumable), but the daemon still HELD the - // repair session — the agent must be able to reach it to close/inspect. const daemon = await startHttpDaemonErrorFixture( heldDivergenceError({ allowed: false, @@ -854,7 +860,7 @@ test('C1: keep-alive keys on repairSessionHeld, NOT resume.allowed — a HELD di assert.equal(fs.existsSync(ownedStateDir), true); } finally { await closeLoopbackServer(daemon.server); - if (ownedStateDir) fs.rmSync(ownedStateDir, { recursive: true, force: true }); + if (ownedStateDir) await finishRegisteredDaemonFixture(ownedStateDir); } }); @@ -883,92 +889,76 @@ test('sendToDaemon tears down an owned ephemeral daemon on an UNHELD divergence if (response.ok) return; assert.equal(response.error.hint, undefined); assert.ok(ownedStateDir.length > 0); - // No held signal (`resume.allowed:true` alone is not the keep-alive key) — - // ordinary one-shot teardown still applies. assert.equal(fs.existsSync(ownedStateDir), false); } finally { await closeLoopbackServer(daemon.server); - if (ownedStateDir) fs.rmSync(ownedStateDir, { recursive: true, force: true }); + if (ownedStateDir) await finishRegisteredDaemonFixture(ownedStateDir); } }); -// --- ADR 0012 decision 6 (BLOCKER 2, third follow-up): a one-shot -// `replay --save-script` that completes with no divergence returns SUCCESS -// immediately — the actual healed-script commit is deferred to daemon -// teardown. If that deferred commit then fails, the daemon leaves a -// REPAIR_COMMIT_FAILED tombstone in the owned state dir before exiting. The -// client cleanup must discover it (after waiting for the daemon to actually -// exit) BEFORE deleting the owned state dir, and must surface it in the -// response the caller receives — never silently delete the only evidence of -// the failure while reporting the success already computed for the replay -// itself. --- - test('BLOCKER 2 (third follow-up): a shutdown-time repair commit failure is surfaced and the owned state dir survives', async (t) => { if (!(await supportsLoopbackBind())) { t.skip('loopback listeners are not permitted in this environment'); return; } - // The daemon's RPC response for the replay itself is a plain SUCCESS (the - // plan completed with no divergence) — exactly what a real daemon would - // return before its deferred, teardown-time commit has even attempted. - const daemon = await startHttpDaemonFixture({ session: 'default' }); - let ownedStateDir = ''; - installSpawnedHttpDaemonAtOwnedStateDir(daemon.port, (dir) => { - ownedStateDir = dir; - // Simulate the daemon's OWN shutdown handler (`finalizeRepairTeardown`) - // having already run and left a commit-failure tombstone before this - // fake process "exits" — the real ordering `stopDaemonProcessForTakeover` - // depends on (it waits for the process to exit, and the real daemon only - // exits after teardown finishes writing this file). - const ownedPaths = resolveDaemonPaths(dir); - const sessionDir = path.join(ownedPaths.sessionsDir, 'default'); - fs.mkdirSync(sessionDir, { recursive: true }); - fs.writeFileSync( - path.join(sessionDir, 'repair-tombstone.json'), - `${JSON.stringify({ - owner: 'default', - reapedAt: Date.now(), - expiresAt: Date.now() + 60_000, - sourcePath: '/tmp/flow.ad', - commitFailure: { - code: 'COMMAND_FAILED', - message: 'a prior healed script already exists at /tmp/flow.healed.ad', - }, - })}\n`, - ); - }); - - try { - const response = await sendToDaemon({ - session: 'default', - command: 'replay', - positionals: ['flow.ad'], - flags: { saveScript: true, daemonTransport: 'http' }, - meta: { requestId: 'req-repair-commit-fail-teardown' }, + for (const failRelease of [false, true]) { + const daemon = await startHttpDaemonFixture({ session: 'default' }); + let ownedStateDir = ''; + installSpawnedHttpDaemonAtOwnedStateDir(daemon.port, (dir) => { + ownedStateDir = dir; + fs.writeFileSync( + path.join(dir, 'repair-on-shutdown.json'), + `${JSON.stringify({ + owner: 'default', + reapedAt: Date.now(), + expiresAt: Date.now() + 60_000, + sourcePath: '/tmp/flow.ad', + commitFailure: { + code: 'COMMAND_FAILED', + message: 'a prior healed script already exists at /tmp/flow.healed.ad', + }, + })}\n`, + ); }); - // The client-visible response must surface the deferred commit failure — - // never the raw success the daemon returned for the replay itself, and - // never silently swallowed by cleanup. - assert.equal(response.ok, false); - if (response.ok) return; - assert.equal(response.error.code, 'REPAIR_COMMIT_FAILED'); - assert.match(response.error.message, /a prior healed script already exists/); - assert.ok(response.error.message.includes('replay /tmp/flow.ad --save-script')); + const originalRmdir = fs.rmdirSync; + const releaseSpy = vi.spyOn(fs, 'rmdirSync').mockImplementation((target, options) => { + if (failRelease && target === resolveDaemonPaths(ownedStateDir).lockPath) + throw Object.assign(new Error('release failed'), { code: 'EBUSY' }); + return originalRmdir(target, options); + }); + try { + const response = await sendToDaemon({ + session: 'default', + command: 'replay', + positionals: ['flow.ad'], + flags: { saveScript: true, daemonTransport: 'http' }, + meta: { requestId: 'req-repair-commit-fail-teardown' }, + }); - // The owned state dir — and the tombstone evidence inside it — must - // survive: never rmSync'd while an unrecovered commit failure is on record. - assert.ok(ownedStateDir.length > 0); - assert.equal(fs.existsSync(ownedStateDir), true); - const ownedPaths = resolveDaemonPaths(ownedStateDir); - assert.equal( - fs.existsSync(path.join(ownedPaths.sessionsDir, 'default', 'repair-tombstone.json')), - true, - ); - } finally { - await closeLoopbackServer(daemon.server); - if (ownedStateDir) fs.rmSync(ownedStateDir, { recursive: true, force: true }); + assert.ok(!response.ok); + assert.equal(response.error.code, 'REPAIR_COMMIT_FAILED'); + const secondary = response.error.details?.cleanupFailure as + | { details?: { ownerReleaseUnverified?: boolean }; hint?: string } + | undefined; + assert.equal(Boolean(secondary?.details?.ownerReleaseUnverified), failRelease); + assert.equal(Boolean(secondary?.hint?.startsWith('Restore process inspection')), failRelease); + assert.match(response.error.message, /a prior healed script already exists/); + assert.ok(response.error.message.includes('replay /tmp/flow.ad --save-script')); + + assert.ok(ownedStateDir.length > 0); + assert.equal(fs.existsSync(ownedStateDir), true); + const ownedPaths = resolveDaemonPaths(ownedStateDir); + assert.equal( + fs.existsSync(path.join(ownedPaths.sessionsDir, 'default', 'repair-tombstone.json')), + true, + ); + } finally { + releaseSpy.mockRestore(); + await closeLoopbackServer(daemon.server); + if (ownedStateDir) await finishRegisteredDaemonFixture(ownedStateDir); + } } }); @@ -1003,7 +993,7 @@ test('continuation: sendToDaemon keeps the daemon alive on a held divergence eve assert.equal(fs.existsSync(ownedStateDir), true); } finally { await closeLoopbackServer(daemon.server); - if (ownedStateDir) fs.rmSync(ownedStateDir, { recursive: true, force: true }); + if (ownedStateDir) await finishRegisteredDaemonFixture(ownedStateDir); } }); @@ -1131,7 +1121,7 @@ test('sendToDaemon keeps an owned ephemeral daemon alive and hints its --state-d assert.equal(fs.existsSync(ownedStateDir), true); } finally { await closeLoopbackServer(daemon.server); - if (ownedStateDir) fs.rmSync(ownedStateDir, { recursive: true, force: true }); + if (ownedStateDir) await finishRegisteredDaemonFixture(ownedStateDir); } }); @@ -1170,7 +1160,7 @@ test('closes the loop: a follow-up sendToDaemon using the hinted --state-dir/--s assert.equal(daemon.rpcRequests[1]?.params?.command, 'press'); } finally { await closeLoopbackServer(daemon.server); - if (ownedStateDir) fs.rmSync(ownedStateDir, { recursive: true, force: true }); + if (ownedStateDir) await finishRegisteredDaemonFixture(ownedStateDir); } }); @@ -1202,7 +1192,7 @@ test('sendToDaemon tears down an owned ephemeral daemon when replay reports the assert.equal(fs.existsSync(ownedStateDir), false); } finally { await closeLoopbackServer(daemon.server); - if (ownedStateDir) fs.rmSync(ownedStateDir, { recursive: true, force: true }); + if (ownedStateDir) await finishRegisteredDaemonFixture(ownedStateDir); } }); @@ -1248,7 +1238,7 @@ test('ADR 0012 R7 x ADR 0016: a completed --save-script repair also keeps its ow assert.equal(fs.existsSync(ownedStateDir), true); } finally { await closeLoopbackServer(daemon.server); - if (ownedStateDir) fs.rmSync(ownedStateDir, { recursive: true, force: true }); + if (ownedStateDir) await finishRegisteredDaemonFixture(ownedStateDir); } }); @@ -1294,7 +1284,7 @@ test('issue #1384: sendToDaemon does not stop a client-started daemon at an expl assert.equal(fs.existsSync(paths.lockPath), true); } finally { await closeLoopbackServer(daemon.server); - fs.rmSync(stateDir, { recursive: true, force: true }); + await finishRegisteredDaemonFixture(stateDir); } }); @@ -1328,6 +1318,6 @@ test('sendToDaemon still tears down a `test` command owned ephemeral daemon even assert.equal(fs.existsSync(ownedStateDir), false); } finally { await closeLoopbackServer(daemon.server); - if (ownedStateDir) fs.rmSync(ownedStateDir, { recursive: true, force: true }); + if (ownedStateDir) await finishRegisteredDaemonFixture(ownedStateDir); } }); diff --git a/src/daemon-client/__tests__/daemon-client-metadata.test.ts b/src/daemon-client/__tests__/daemon-client-metadata.test.ts index 5b46a91e8d..bae4084103 100644 --- a/src/daemon-client/__tests__/daemon-client-metadata.test.ts +++ b/src/daemon-client/__tests__/daemon-client-metadata.test.ts @@ -1,27 +1,13 @@ import assert from 'node:assert/strict'; -import { AppError, normalizeError } from '@agent-device/kernel/errors'; import fs from 'node:fs'; import path from 'node:path'; -import { afterEach, test, vi } from 'vitest'; +import { test } from 'vitest'; import type { DaemonCodeOrigin } from '@agent-device/host-kit/code-signature'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; import { tryAcquireDaemonRegistration } from '../../daemon-registration-owner.ts'; -import { - readDaemonInfo, - cleanupFailedDaemonStartupMetadata, - stopDaemonProcessForTakeover, - type DaemonInfo, -} from '../daemon-client-metadata.ts'; -import { isAgentDeviceDaemonProcess, stopDaemonProcess } from '../../daemon-process.ts'; +import { readDaemonInfo, type DaemonInfo } from '../daemon-client-metadata.ts'; import { resolveDaemonPaths } from '../../daemon-resolution.ts'; -vi.mock('../../daemon-process.ts', async (importOriginal) => ({ - ...(await importOriginal()), - isAgentDeviceDaemonProcess: vi.fn(), - stopDaemonProcess: vi.fn(), -})); -afterEach(() => vi.resetAllMocks()); - // The reuse decision is only as good as the identity that survives the round trip // through `daemon.json`: a client cannot compare what the file lost (#2458). @@ -66,39 +52,3 @@ test('a registration this version did not write reads back unreported', () => { assert.equal(readDaemonInfo(infoPath)?.codeOrigin, undefined); } }); - -for (const artifact of ['daemon.json', 'daemon.lock']) { - test(`unconfirmed startup stop retains ${artifact} without claiming cleanup`, async () => { - const [stateDir] = scratchStateDir(); - const paths = resolveDaemonPaths(stateDir); - const file = path.join(stateDir, artifact); - const contents = JSON.stringify({ - pid: 7, - processStartTime: 'start', - port: 1234, - token: 'secret', - }); - fs.writeFileSync(file, contents); - vi.mocked(isAgentDeviceDaemonProcess).mockReturnValue(true); - vi.mocked(stopDaemonProcess).mockResolvedValue({ status: 'retained', reason: 'exit-timeout' }); - const result = await cleanupFailedDaemonStartupMetadata(paths, 'start_error'); - assert.equal(fs.readFileSync(file, 'utf8'), contents); - assert.equal(result.removedInfo, false); - assert.equal(result.removedLock, false); - assert.equal(result.stoppedInfoProcess, false); - assert.equal(result.stoppedLockProcess, false); - assert.match(result.error ?? '', /exit could not be confirmed/); - }); -} - -test('a retained takeover keeps its reason at the normalized error boundary', async () => { - vi.mocked(stopDaemonProcess).mockResolvedValue({ status: 'retained', reason: 'exit-timeout' }); - await assert.rejects( - stopDaemonProcessForTakeover({ pid: 7, token: 'secret', processStartTime: 'start' }), - (error: unknown) => { - assert.ok(error instanceof AppError); - assert.equal(normalizeError(error).details?.reason, 'daemon_exit_unconfirmed'); - return true; - }, - ); -}); diff --git a/src/daemon-client/__tests__/daemon-client-startup-race.test.ts b/src/daemon-client/__tests__/daemon-client-startup-race.test.ts index d52ba0e6c2..60eb5a9366 100644 --- a/src/daemon-client/__tests__/daemon-client-startup-race.test.ts +++ b/src/daemon-client/__tests__/daemon-client-startup-race.test.ts @@ -1,7 +1,24 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; -import { afterEach, beforeAll, test, vi } from 'vitest'; +import path from 'node:path'; +import { afterEach, test, vi } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; +import { tryAcquireProcessLock, inspectProcessLock } from '@agent-device/host-kit/file'; +import { readCurrentOwnerIdentity, isProcessAlive } from '@agent-device/host-kit/process'; +import { readVersion } from '@agent-device/host-kit/version'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { + spawnRegisteredDaemonFixture, + finishRegisteredDaemonFixtures, +} from '../../__tests__/test-utils/registered-daemon-fixture.ts'; +import { + startHttpDaemonFixture, + currentDaemonCodeSignature, +} from '../../__tests__/test-utils/daemon-http-fixture.ts'; +import { closeLoopbackServer, supportsLoopbackBind } from '../../__tests__/test-utils/loopback.ts'; +import { resolveDaemonPaths, type DaemonPaths } from '../../daemon-resolution.ts'; +import { sendToDaemon } from '../daemon-client.ts'; +import { DAEMON_STARTUP_EXIT_CODES } from '../../daemon-registration-owner.ts'; vi.mock('@agent-device/host-kit/command', async (importOriginal) => ({ ...(await importOriginal()), @@ -9,205 +26,383 @@ vi.mock('@agent-device/host-kit/command', async (importOriginal) => ({ })); vi.mock('@agent-device/host-kit/retry', async (importOriginal) => ({ ...(await importOriginal()), - sleep: vi.fn(async () => {}), + sleep: vi.fn(), })); -const winner = vi.hoisted(() => ({ pid: 43_300, alive: true })); -vi.mock('../../daemon-process.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - isAgentDeviceDaemonProcess: vi.fn((pid: number, startTime: string | undefined) => - pid === winner.pid ? winner.alive : actual.isAgentDeviceDaemonProcess(pid, startTime), - ), - stopDaemonProcess: vi.fn( - async ( - identity: Parameters[0], - options: Parameters[1], - ) => { - if (identity.pid !== winner.pid) return await actual.stopDaemonProcess(identity, options); - winner.alive = false; - return { - status: 'exited' as const, - identity: { pid: identity.pid, startTime: identity.startTime! }, - mode: 'graceful' as const, - }; - }, - ), - }; -}); - -import { resolveDaemonPaths, type DaemonPaths } from '../../daemon-resolution.ts'; -import { sendToDaemon } from '../daemon-client.ts'; import { runCmdDetachedMonitored, type ExecDetachedExit } from '@agent-device/host-kit/command'; import { sleep } from '@agent-device/host-kit/retry'; -import { readVersion } from '@agent-device/host-kit/version'; -import { resolveLocalDaemonCodeIdentity } from '../daemon-launch-spec.ts'; -import { - startHttpDaemonFixture, - type HttpDaemonFixture, -} from '../../__tests__/test-utils/daemon-http-fixture.ts'; -import { closeLoopbackServer, supportsLoopbackBind } from '../../__tests__/test-utils/loopback.ts'; - -// Two clients that find no daemon both launch one; the daemon that loses the startup lock exits -// cleanly. These pin that the losing client adopts the winner instead of tearing it down. - -const WINNER_PID = winner.pid; -const LOSER_PID = 43_301; - -const mockRunCmdDetached = vi.mocked(runCmdDetachedMonitored); -const mockSleep = vi.mocked(sleep); - -afterEach(() => { - winner.alive = true; - mockRunCmdDetached.mockReset(); - mockSleep.mockReset(); - mockSleep.mockImplementation(async () => {}); - vi.unstubAllEnvs(); +const actualRetry = await vi.importActual( + '@agent-device/host-kit/retry', +); +const spawn = vi.mocked(runCmdDetachedMonitored); +const pause = vi.mocked(sleep); +afterEach(async () => { + vi.restoreAllMocks(); + await finishRegisteredDaemonFixtures(); + spawn.mockReset(); + pause.mockReset(); }); -/** The code signature this client stamps on, and expects of, a daemon it may reuse. */ -let codeSignature: string | undefined; - -beforeAll(async () => { - const identity = await resolveLocalDaemonCodeIdentity(); - codeSignature = identity.origin === 'installed' ? undefined : identity.codeSignature; -}); +function request(paths: DaemonPaths, command = 'devices') { + return { + session: 'default', + command, + positionals: [], + flags: { stateDir: paths.baseDir, daemonTransport: 'http' as const }, + }; +} +function fields(httpPort: number, version = readVersion()) { + return { + httpPort, + token: 'secret', + version, + codeOrigin: 'checkout' as const, + codeSignature: currentDaemonCodeSignature(), + }; +} +async function awaitFile(file: string) { + const deadline = Date.now() + 2_000; + while (!fs.existsSync(file)) { + assert.ok(Date.now() < deadline, `fixture did not publish ${file}`); + await actualRetry.sleep(10); + } +} -/** Records the winning daemon the way it would: the startup lock, then its reachable metadata. */ -function writeWinner( - paths: DaemonPaths, - fixture: HttpDaemonFixture, - parts: 'lock' | 'all', - version = readVersion(), -): void { - fs.mkdirSync(paths.baseDir, { recursive: true }); - fs.writeFileSync( - paths.lockPath, - JSON.stringify({ pid: WINNER_PID, processStartTime: 'winner', startedAt: Date.now() }), - ); - if (parts === 'lock') return; - fs.writeFileSync( - paths.infoPath, - JSON.stringify({ - token: 'winner-secret', - pid: WINNER_PID, - version, - codeSignature, - processStartTime: 'winner', - httpPort: fixture.port, - transport: 'http', - }), - ); +for (const command of ['devices', 'test']) { + test(`a joined busy contender adopts a real winner for ${command}`, async (t) => { + if (!(await supportsLoopbackBind())) return t.skip('loopback unavailable'); + const paths = resolveDaemonPaths(mkdtempForTestSync('daemon-start-winner-')); + const http = await startHttpDaemonFixture({ devices: [] }); + const deferred = path.join(paths.baseDir, 'defer-publication'); + fs.writeFileSync(deferred, 'wait'); + const winner = spawnRegisteredDaemonFixture(paths, fields(http.port), { stdio: 'ignore' }); + await awaitFile(path.join(paths.baseDir, 'registration-held')); + fs.writeFileSync( + paths.infoPath, + JSON.stringify({ ...fields(http.port, '0.0.1'), pid: 999_999_999, processStartTime: 'old' }), + ); + let joined = false; + let genuineExit: ExecDetachedExit | undefined; + let contender: ReturnType | undefined; + let releaseJoin: (exit: ExecDetachedExit) => void = () => {}; + let pauses = 0; + spawn.mockImplementation((_command, _args, options) => { + contender = spawnRegisteredDaemonFixture(paths, fields(http.port), options); + void contender.exited.then((exit) => { + assert.equal(exit.exitCode, DAEMON_STARTUP_EXIT_CODES.busy); + genuineExit = exit; + }); + return { + ...contender, + exited: new Promise((resolve) => { + releaseJoin = resolve; + }), + }; + }); + pause.mockImplementation(async (ms) => { + pauses += 1; + fs.rmSync(deferred, { force: true }); + await awaitFile(paths.infoPath); + await actualRetry.sleep(ms); + if (pauses >= 2 && genuineExit) { + joined = true; + releaseJoin(genuineExit); + } + }); + try { + const response = await sendToDaemon(request(paths, command)); + assert.equal(response.ok, true); + assert.equal(joined, true); + assert.equal(spawn.mock.calls.length, 1); + assert.equal(http.rpcRequests.length, 1); + assert.equal(isProcessAlive(winner.pid), true); + const claim = inspectProcessLock(paths.lockPath); + assert.equal(claim.state, 'held'); + if (claim.state === 'held') assert.equal(claim.owner.pid, winner.pid); + } finally { + if (contender) releaseJoin(await contender.exited); + await closeLoopbackServer(http.server); + } + }); } -test('a client whose daemon lost the startup lock uses the daemon that won it', async (t) => { - if (!(await supportsLoopbackBind())) { - t.skip('loopback listeners are not permitted in this environment'); - return; - } - const stateDir = mkdtempForTestSync('agent-device-daemon-start-race-'); - const paths = resolveDaemonPaths(stateDir); - vi.stubEnv('AGENT_DEVICE_STATE_DIR', stateDir); - const fixture = await startHttpDaemonFixture({ devices: [] }); - let launches = 0; - mockRunCmdDetached.mockImplementation(() => { - launches += 1; - writeWinner(paths, fixture, 'lock'); - const exit: ExecDetachedExit = { pid: LOSER_PID, exitCode: 0 }; - return { pid: LOSER_PID, exited: Promise.resolve(exit) }; +test('a client-held claim is waited out before a fresh daemon attempt', async (t) => { + if (!(await supportsLoopbackBind())) return t.skip('loopback unavailable'); + const paths = resolveDaemonPaths(mkdtempForTestSync('daemon-start-client-holder-')); + const http = await startHttpDaemonFixture({ devices: [] }); + const claim = tryAcquireProcessLock({ + lockDirPath: paths.lockPath, + owner: { ...readCurrentOwnerIdentity(), acquiredAtMs: Date.now() }, }); - mockSleep.mockImplementation(async () => { - if (!fs.existsSync(paths.infoPath)) writeWinner(paths, fixture, 'all'); + assert.equal(claim.status, 'acquired'); + if (claim.status !== 'acquired') throw new Error('fixture claim refused'); + let loserJoined = false; + let released = false; + spawn.mockImplementation((_command, _args, options) => { + const child = spawnRegisteredDaemonFixture(paths, fields(http.port), options); + if (spawn.mock.calls.length === 1) + void child.exited.then((exit) => { + assert.equal(exit.exitCode, DAEMON_STARTUP_EXIT_CODES.busy); + loserJoined = true; + }); + else assert.equal(loserJoined, true); + return child; + }); + pause.mockImplementation(async (ms) => { + if (loserJoined && !released) { + await claim.acquisition.release(); + released = true; + } + await actualRetry.sleep(ms); }); - try { - const response = await sendToDaemon({ - session: 'default', - command: 'devices', - positionals: [], - flags: { stateDir }, - meta: { requestId: 'req-start-race' }, - }); - - assert.equal(response.ok, true); - assert.equal(launches, 1); - assert.equal(fixture.rpcRequests.length, 1); - assert.equal(fs.existsSync(paths.infoPath), true); - assert.equal(fs.existsSync(paths.lockPath), true); + assert.equal((await sendToDaemon(request(paths))).ok, true); + assert.equal(spawn.mock.calls.length, 2); + assert.equal(http.rpcRequests.length, 1); } finally { - await closeLoopbackServer(fixture.server); - fs.rmSync(stateDir, { recursive: true, force: true }); + if (!released) await claim.acquisition.release(); + await closeLoopbackServer(http.server); } }); -test('a one-shot test run leaves a daemon another client started running', async (t) => { - if (!(await supportsLoopbackBind())) { - t.skip('loopback listeners are not permitted in this environment'); - return; - } - const stateDir = mkdtempForTestSync('agent-device-daemon-start-race-owner-'); - const paths = resolveDaemonPaths(stateDir); - vi.stubEnv('AGENT_DEVICE_STATE_DIR', stateDir); - const fixture = await startHttpDaemonFixture({ passed: 1, failed: 0 }); - mockRunCmdDetached.mockImplementation(() => { - writeWinner(paths, fixture, 'all'); - return { pid: LOSER_PID, exited: new Promise(() => {}) }; +for (const exit of [ + { exitCode: 0 }, + { exitCode: 1 }, + { exitCode: DAEMON_STARTUP_EXIT_CODES.unproven }, + { exitCode: DAEMON_STARTUP_EXIT_CODES.busy, error: 'spawn refused' }, + { exitCode: DAEMON_STARTUP_EXIT_CODES.busy, signal: 'SIGTERM' as const }, +]) { + test(`generic exit ${exit.error ?? exit.signal ?? exit.exitCode} cannot adopt or stop a foreign winner`, async (t) => { + if (!(await supportsLoopbackBind())) return t.skip('loopback unavailable'); + const paths = resolveDaemonPaths(mkdtempForTestSync('daemon-start-generic-exit-')); + const http = await startHttpDaemonFixture({ devices: [] }); + const deferred = path.join(paths.baseDir, 'defer-publication'); + fs.writeFileSync(deferred, 'wait'); + const winner = spawnRegisteredDaemonFixture(paths, fields(http.port), { stdio: 'ignore' }); + await awaitFile(path.join(paths.baseDir, 'registration-held')); + spawn.mockImplementation(() => ({ + pid: 999_999, + exited: Promise.resolve({ pid: 999_999, ...exit }), + })); + pause.mockImplementation(async (ms) => { + fs.rmSync(deferred, { force: true }); + await actualRetry.sleep(ms); + }); + try { + await assert.rejects( + sendToDaemon(request(paths)), + (error: unknown) => + error instanceof AppError && error.details?.kind === 'daemon_startup_failed', + ); + assert.equal(spawn.mock.calls.length, 1); + assert.equal(http.rpcRequests.length, 0); + assert.equal(isProcessAlive(winner.pid), true); + assert.equal(inspectProcessLock(paths.lockPath).state, 'held'); + } finally { + await closeLoopbackServer(http.server); + } }); +} +test('a joined busy contender waits for a published winner to become ready without signaling it', async (t) => { + if (!(await supportsLoopbackBind())) return t.skip('loopback unavailable'); + const paths = resolveDaemonPaths(mkdtempForTestSync('daemon-start-delayed-ready-')); + let probes = 0; + const http = await startHttpDaemonFixture({ devices: [] }, { ready: () => ++probes >= 12 }); + const deferred = path.join(paths.baseDir, 'defer-publication'); + fs.writeFileSync(deferred, 'wait'); + const winner = spawnRegisteredDaemonFixture(paths, fields(http.port), { stdio: 'ignore' }); + await awaitFile(path.join(paths.baseDir, 'registration-held')); + let contenderExit: ExecDetachedExit | undefined; + spawn.mockImplementation((_command, _args, options) => { + const child = spawnRegisteredDaemonFixture(paths, fields(http.port), options); + void child.exited.then((exit) => { + contenderExit = exit; + }); + return child; + }); + pause.mockImplementation(async () => { + if (contenderExit) fs.rmSync(deferred, { force: true }); + await actualRetry.sleep(10); + }); + const signal = vi.spyOn(process, 'kill'); try { - const response = await sendToDaemon({ - session: 'default', - command: 'test', - positionals: [], - flags: { stateDir }, - meta: { requestId: 'req-start-race-test' }, + assert.equal((await sendToDaemon(request(paths))).ok, true); + assert.equal(contenderExit?.exitCode, DAEMON_STARTUP_EXIT_CODES.busy); + assert.ok(probes >= 12); + assert.equal(spawn.mock.calls.length, 1); + assert.equal(http.rpcRequests.length, 1); + assert.equal(isProcessAlive(winner.pid), true); + assert.equal( + signal.mock.calls.some(([pid, kind]) => pid === winner.pid && kind !== 0), + false, + ); + } finally { + signal.mockRestore(); + await closeLoopbackServer(http.server); + } +}); + +for (const held of [true, false]) { + test(`startup uses one deadline when the claim is ${held ? 'held' : 'released for relaunch'}`, async () => { + const paths = resolveDaemonPaths(mkdtempForTestSync('daemon-start-budget-')); + const claim = tryAcquireProcessLock({ + lockDirPath: paths.lockPath, + owner: { ...readCurrentOwnerIdentity(), acquiredAtMs: Date.now() }, + }); + assert.equal(claim.status, 'acquired'); + if (claim.status !== 'acquired') throw new Error('fixture claim refused'); + let now = Date.now(); + const started = now; + let released = false; + let advanced = false; + let finishPending: () => void = () => {}; + const nativeTimeout = globalThis.setTimeout; + vi.spyOn(globalThis, 'setTimeout').mockImplementation((handler, ms, ...args) => + nativeTimeout(handler, ms === 1_000 ? 0 : ms, ...args), + ); + vi.spyOn(Date, 'now').mockImplementation(() => now); + spawn.mockImplementation(() => ({ + pid: 999_999, + exited: + spawn.mock.calls.length === 1 + ? Promise.resolve({ pid: 999_999, exitCode: DAEMON_STARTUP_EXIT_CODES.busy }) + : new Promise((resolve) => { + finishPending = () => resolve({ pid: 999_999, exitCode: 1 }); + }), + })); + pause.mockImplementation(async (ms) => { + if (!advanced) { + if (!held) { + await claim.acquisition.release(); + released = true; + } + advanced = true; + now += 14_750; + } else now += ms; }); + try { + await assert.rejects(sendToDaemon(request(paths)), (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.startupAttempts, held ? 1 : 2); + assert.equal(error.details?.startupTimeoutMs, 15_000); + return true; + }); + assert.equal(now - started, 15_000); + assert.equal(inspectProcessLock(paths.lockPath).state, held ? 'held' : 'absent'); + } finally { + finishPending(); + vi.restoreAllMocks(); + if (!released) await claim.acquisition.release(); + } + }); +} - assert.equal(response.ok, true); - assert.equal(fs.existsSync(paths.infoPath), true); +test.for([ + { budget: 'ample', offset: 0, launches: 2, alive: false, rpcs: 1 }, + { budget: 'near deadline', offset: 11_000, launches: 1, alive: true, rpcs: 0 }, +])('an older winner is replaced only with enough startup time ($budget)', async (expected, t) => { + if (!(await supportsLoopbackBind())) return t.skip('loopback unavailable'); + const paths = resolveDaemonPaths(mkdtempForTestSync('daemon-start-older-winner-')); + const http = await startHttpDaemonFixture({ devices: [] }); + const deferred = path.join(paths.baseDir, 'defer-publication'); + fs.writeFileSync(deferred, 'wait'); + const winner = spawnRegisteredDaemonFixture(paths, fields(http.port, '0.0.1'), { + stdio: 'ignore', + }); + await awaitFile(path.join(paths.baseDir, 'registration-held')); + const wallTime = Date.now; + let offset = 0; + const clock = vi.spyOn(Date, 'now').mockImplementation(() => wallTime() + offset); + let joined = false; + spawn.mockImplementation((_command, _args, options) => { + if (spawn.mock.calls.length > 1) assert.equal(joined, true); + const child = spawnRegisteredDaemonFixture(paths, fields(http.port), options); + if (spawn.mock.calls.length === 1) + void child.exited.then((exit) => { + assert.equal(exit.exitCode, DAEMON_STARTUP_EXIT_CODES.busy); + joined = true; + }); + return child; + }); + pause.mockImplementation(async (ms) => { + if (joined) { + fs.rmSync(deferred, { force: true }); + if (expected.offset) offset = offset ? offset + ms : expected.offset; + } + await actualRetry.sleep(10); + }); + const notice = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + const pending = sendToDaemon(request(paths)); + if (expected.alive) + await assert.rejects(pending, (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.kind, 'daemon_startup_failed'); + assert.equal(error.details?.startupAttempts, 1); + assert.equal(error.details?.startupTimeoutMs, 15_000); + return true; + }); + else { + assert.equal((await pending).ok, true); + await winner.exited; + } + assert.equal(joined, true); + assert.equal(isProcessAlive(winner.pid), expected.alive); + assert.equal(spawn.mock.calls.length, expected.launches); + assert.equal(http.rpcRequests.length, expected.rpcs); + assert.equal( + notice.mock.calls.flat().join('').includes(`Replacing daemon (pid ${winner.pid}, v0.0.1)`), + !expected.alive, + ); } finally { - await closeLoopbackServer(fixture.server); - fs.rmSync(stateDir, { recursive: true, force: true }); + clock.mockRestore(); + notice.mockRestore(); + await closeLoopbackServer(http.server); } }); -test('a start race won by an older daemon replaces it instead of adopting it', async (t) => { - if (!(await supportsLoopbackBind())) { - t.skip('loopback listeners are not permitted in this environment'); - return; - } - const stateDir = mkdtempForTestSync('agent-device-daemon-start-race-older-'); - const paths = resolveDaemonPaths(stateDir); - vi.stubEnv('AGENT_DEVICE_STATE_DIR', stateDir); - const fixture = await startHttpDaemonFixture({ devices: [] }); - let launches = 0; - mockRunCmdDetached.mockImplementation(() => { - launches += 1; - if (launches === 1) writeWinner(paths, fixture, 'all', '0.0.1'); - const exit: ExecDetachedExit = { pid: LOSER_PID, exitCode: 0 }; - return { pid: LOSER_PID, exited: Promise.resolve(exit) }; +test('a failed own transport probe retires and joins the private startup before rejecting', async (t) => { + if (!(await supportsLoopbackBind())) return t.skip('loopback unavailable'); + const http = await startHttpDaemonFixture({ devices: [] }); + let paths: DaemonPaths | undefined; + let child: ReturnType | undefined; + let failure: AppError | undefined; + spawn.mockImplementation((_command, _args, options) => { + paths = resolveDaemonPaths(String(options?.env?.AGENT_DEVICE_STATE_DIR)); + child = spawnRegisteredDaemonFixture(paths, fields(http.port), options); + return child; }); - const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); - + pause.mockImplementation(actualRetry.sleep); try { await assert.rejects( sendToDaemon({ session: 'default', - command: 'devices', + command: 'test', positionals: [], - flags: { stateDir }, - meta: { requestId: 'req-start-race-older' }, + flags: { daemonTransport: 'socket', daemonServerMode: 'http' }, }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.message, 'Daemon socket endpoint is unavailable'); + assert.equal(error.details?.reason, 'daemon_endpoint_unavailable'); + failure = error; + return true; + }, ); - assert.equal(fixture.rpcRequests.length, 0); - assert.equal(launches, 2); - assert.match( - String(stderr.mock.calls.flat().join('')), - /Replacing daemon \(pid 43300, v0\.0\.1\)/, - ); + assert.ok(paths && child && failure); + assert.equal(isProcessAlive(child.pid), false); + assert.equal(fs.existsSync(paths.baseDir), false); + await child.exited; + assert.equal(failure.details?.startupJoined, true); + assert.equal(failure.details?.stateDir, paths.baseDir); + const results = failure.details?.cleanupResults as Array<{ + status: string; + removedStateDir?: boolean; + }>; + assert.equal(results[0]?.status, 'retired'); + assert.equal(results[0]?.removedStateDir, true); + assert.equal(http.rpcRequests.length, 0); } finally { - stderr.mockRestore(); - await closeLoopbackServer(fixture.server); - fs.rmSync(stateDir, { recursive: true, force: true }); + await closeLoopbackServer(http.server); } }); diff --git a/src/daemon-client/__tests__/daemon-client-timeout-route.test.ts b/src/daemon-client/__tests__/daemon-client-timeout-route.test.ts index f02582c40e..88415d1a23 100644 --- a/src/daemon-client/__tests__/daemon-client-timeout-route.test.ts +++ b/src/daemon-client/__tests__/daemon-client-timeout-route.test.ts @@ -26,19 +26,11 @@ import net from 'node:net'; import http from 'node:http'; import path from 'node:path'; +import fs from 'node:fs'; import assert from 'node:assert/strict'; import { beforeEach, afterEach, test, vi } from 'vitest'; -const { mockRunCmdSync, mockIsDaemon, mockStop } = vi.hoisted(() => ({ - mockRunCmdSync: vi.fn(), - mockIsDaemon: vi.fn(), - mockStop: vi.fn(), -})); -vi.mock('../../daemon-process.ts', async (importOriginal) => ({ - ...(await importOriginal()), - isAgentDeviceDaemonProcess: mockIsDaemon, - stopDaemonProcess: mockStop, -})); +const { mockRunCmdSync } = vi.hoisted(() => ({ mockRunCmdSync: vi.fn() })); vi.mock('@agent-device/host-kit/command', async () => { const actual = await vi.importActual( @@ -47,18 +39,31 @@ vi.mock('@agent-device/host-kit/command', async () => { return { ...actual, runCmdSync: mockRunCmdSync }; }); -import { AppError } from '@agent-device/kernel/errors'; +import { AppError, normalizeError } from '@agent-device/kernel/errors'; +import { sleep } from '@agent-device/host-kit/retry'; +import { withDiagnosticsScope } from '@agent-device/host-kit/diagnostics'; import { sendRequest } from '../daemon-client-transport.ts'; import type { DaemonRequest } from '../../daemon/daemon-request.ts'; import type { DaemonInfo } from '../daemon-client-metadata.ts'; -import type { DaemonPaths } from '../../daemon-resolution.ts'; +import { resolveDaemonPaths, type DaemonPaths } from '../../daemon-resolution.ts'; +import type { DaemonRetirementResult } from '../../daemon-registration-owner.ts'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { + spawnRegisteredDaemonFixture, + waitForRegisteredDaemonFixture, + finishRegisteredDaemonFixture, + finishRegisteredDaemonFixtures, +} from '../../__tests__/test-utils/registered-daemon-fixture.ts'; +import { + closeLoopbackServer, + skipWhenLoopbackUnavailable, +} from '../../__tests__/test-utils/loopback.ts'; const TIMEOUT_MS = 120; // `snapshot`'s timeout policy preserves the daemon (onTimeout !== -// 'reset-daemon'), so `handleRequestTimeout` never reaches -// `resetDaemonAfterTimeout` (`process.kill`) here — keeping this suite +// 'reset-daemon'), so `handleRequestTimeout` never signals the daemon +// in the hint controls — keeping them // side-effect-free outside the mocked pkill sweep. const SNAPSHOT_COMMAND = 'snapshot'; @@ -94,6 +99,7 @@ function startHangingSocketServer(): Promise<{ server: net.Server; port: number // Accept the connection but never write a response — forces the // client's own request-timeout envelope to fire. socket.on('error', () => {}); + socket.resume(); }); server.on('error', reject); server.listen(0, '127.0.0.1', () => { @@ -129,10 +135,11 @@ function startHangingHttpServer(): Promise<{ server: http.Server; port: number } beforeEach(() => { mockRunCmdSync.mockReset(); - mockIsDaemon.mockReset(); - mockStop.mockReset(); }); -afterEach(() => vi.restoreAllMocks()); +afterEach(async () => { + vi.restoreAllMocks(); + await finishRegisteredDaemonFixtures(); +}); test('socket timeout: pkill cleanup still runs for a declared non-Apple platform that actually terminates a runner (rebound-session case), and the hint claims Apple on that evidence', async () => { // Simulates --session-lock strip silently rebinding this request onto an @@ -279,32 +286,205 @@ test('remote HTTP timeout never runs the Apple pkill cleanup and uses the remote assert.equal(mockRunCmdSync.mock.calls.length, 0); }); -test('a refused timeout fallback preserves the timeout without an unhandled rejection', async () => { +async function waitForForceStop(requested: Promise): Promise { + let timer: ReturnType | undefined; + try { + await Promise.race([ + requested, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error('force stop was not requested')), 1_500); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +function timeoutDiagnostic(paths: DaemonPaths): Record { + const events = fs + .readFileSync(path.join(paths.baseDir, 'timeout-diagnostics.ndjson'), 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + const event = events.find((entry) => entry.phase === 'daemon_request_timeout'); + assert.ok(event); + return event.data; +} + +function assertForcedRetirement( + retirement: DaemonRetirementResult | undefined, + removalFails: boolean, +): void { + if (removalFails) { + assert.ok(retirement?.status === 'retained'); + assert.equal(retirement.reason, 'retirement-unconfirmed'); + assert.ok(retirement.termination?.status === 'exited'); + assert.equal(retirement.termination.mode, 'forced'); + } else { + assert.ok(retirement?.status === 'retired'); + assert.equal(retirement.termination.mode, 'forced'); + } +} + +for (const transport of ['socket', 'http'] as const) { + for (const removalFails of [false, true]) { + test(`${transport} timeout awaits force exit when metadata removal ${removalFails ? 'fails' : 'succeeds'}`, async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + mockRunCmdSync.mockReturnValue({ exitCode: 1, stdout: '', stderr: '' }); + const endpoint = await (transport === 'socket' + ? startHangingSocketServer() + : startHangingHttpServer()); + const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-timeout-owner-')); + const child = spawnRegisteredDaemonFixture( + paths, + { + ...(transport === 'socket' ? { socketPort: endpoint.port } : { httpPort: endpoint.port }), + token: 'test-token', + version: 'test', + codeOrigin: 'checkout', + codeSignature: 'test', + }, + undefined, + ); + let exited = false; + void child.exited.then(() => { + exited = true; + }); + let killRequested!: () => void; + const requested = new Promise((resolve) => { + killRequested = resolve; + }); + const actualKill = process.kill.bind(process); + let settled = false; + let outcome: Promise | undefined; + const kill = vi.spyOn(process, 'kill').mockImplementation((pid, signal) => { + if (pid === child.pid && signal === 'SIGKILL') { + killRequested(); + return true; + } + return actualKill(pid, signal); + }); + const actualUnlink = fs.unlinkSync.bind(fs); + const remove = vi.spyOn(fs, 'unlinkSync').mockImplementation((file) => { + if (removalFails && file === paths.infoPath) + throw Object.assign(new Error('retained registration control'), { code: 'EACCES' }); + actualUnlink(file); + }); + try { + const info = await waitForRegisteredDaemonFixture(paths, child); + outcome = withDiagnosticsScope( + { debug: true, logPath: path.join(paths.baseDir, 'timeout-diagnostics.ndjson') }, + () => + sendRequest( + info, + { ...buildRequest(undefined), command: 'open' }, + transport, + paths, + TIMEOUT_MS, + ), + ).then( + () => assert.fail('hanging request unexpectedly succeeded'), + (error: unknown) => { + settled = true; + return error; + }, + ); + await waitForForceStop(requested); + await sleep(30); + assert.equal(actualKill(child.pid, 0), true); + assert.equal(settled, false, 'request must remain pending while the daemon is alive'); + kill.mockRestore(); + actualKill(child.pid, 'SIGKILL'); + await child.exited; + const error = await outcome; + assert.ok(error instanceof AppError); + assert.equal(normalizeError(error).details?.reason, 'daemon_transport_timeout'); + assertForcedRetirement( + error.details?.retirement as DaemonRetirementResult | undefined, + removalFails, + ); + assert.equal(fs.existsSync(paths.infoPath), removalFails); + assert.equal(fs.existsSync(paths.lockPath), false); + assert.equal(fs.existsSync(paths.baseDir), true); + assert.equal(timeoutDiagnostic(paths).daemonPreservedAfterTimeout, false); + assert.equal(timeoutDiagnostic(paths).daemonPidForceKilled, true); + assert.equal(mockRunCmdSync.mock.calls.filter(([cmd]) => cmd === 'pkill').length, 3); + } finally { + remove.mockRestore(); + kill.mockRestore(); + if (!exited) actualKill(child.pid, 'SIGKILL'); + await child.exited; + await outcome; + await finishRegisteredDaemonFixture(paths.baseDir); + await closeLoopbackServer(endpoint.server); + } + }); + } +} + +test('timeout retains a live registration without captured birth proof and reports that outcome', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; mockRunCmdSync.mockReturnValue({ exitCode: 1, stdout: '', stderr: '' }); - mockIsDaemon.mockReturnValue(true); - mockStop.mockResolvedValue({ status: 'retained', reason: 'exit-timeout' }); - vi.spyOn(process, 'kill').mockImplementation(() => { - throw Object.assign(new Error('refused'), { code: 'EPERM' }); - }); - const { server, port } = await startHangingSocketServer(); + const endpoint = await startHangingHttpServer(); + const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-timeout-retained-')); + const child = spawnRegisteredDaemonFixture( + paths, + { + httpPort: endpoint.port, + token: 'test-token', + version: 'test', + codeOrigin: 'checkout', + codeSignature: 'test', + }, + undefined, + ); + const kill = vi.spyOn(process, 'kill'); try { + const info = await waitForRegisteredDaemonFixture(paths, child); + const before = fs.readFileSync(paths.infoPath, 'utf8'); + const lockBefore = fs + .readdirSync(paths.lockPath) + .map((name) => [name, fs.readFileSync(path.join(paths.lockPath, name), 'utf8')]); await assert.rejects( - sendRequest( - { port, pid: 7, token: 'test-token', processStartTime: 'start' }, - { ...buildRequest(undefined), command: 'open' }, - 'socket', - dummyStatePaths(), - TIMEOUT_MS, + withDiagnosticsScope( + { debug: true, logPath: path.join(paths.baseDir, 'timeout-diagnostics.ndjson') }, + () => + sendRequest( + { ...info, processStartTime: undefined }, + { ...buildRequest(undefined), command: 'open' }, + 'http', + paths, + TIMEOUT_MS, + ), ), (error: unknown) => { assert.ok(error instanceof AppError); - assert.equal(error.details?.reason, 'daemon_transport_timeout'); + assert.equal(normalizeError(error).details?.reason, 'daemon_transport_timeout'); + const retirement = error.details?.retirement as DaemonRetirementResult | undefined; + assert.ok(retirement?.status === 'retained'); + assert.ok(retirement.termination?.status === 'retained'); + assert.equal(retirement.termination.reason, 'missing-start-time'); + assert.match(normalizeError(error).hint ?? '', /State was retained/); + assert.doesNotMatch(normalizeError(error).hint ?? '', /daemon was reset/); return true; }, ); - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(mockStop.mock.calls.length, 1); + assert.equal(timeoutDiagnostic(paths).daemonPreservedAfterTimeout, true); + assert.equal(process.kill(child.pid, 0), true); + assert.equal(fs.readFileSync(paths.infoPath, 'utf8'), before); + assert.deepEqual( + fs + .readdirSync(paths.lockPath) + .map((name) => [name, fs.readFileSync(path.join(paths.lockPath, name), 'utf8')]), + lockBefore, + ); + assert.equal( + kill.mock.calls.some(([pid, signal]) => pid === child.pid && signal !== 0), + false, + ); } finally { - server.close(); + kill.mockRestore(); + await finishRegisteredDaemonFixture(paths.baseDir); + await closeLoopbackServer(endpoint.server); } }); diff --git a/src/daemon-client/__tests__/daemon-client-transport.test.ts b/src/daemon-client/__tests__/daemon-client-transport.test.ts index d9b60a8bd8..d9860c241a 100644 --- a/src/daemon-client/__tests__/daemon-client-transport.test.ts +++ b/src/daemon-client/__tests__/daemon-client-transport.test.ts @@ -1,6 +1,9 @@ import assert from 'node:assert/strict'; import http from 'node:http'; +import net from 'node:net'; import { test, vi } from 'vitest'; +import * as hostTransport from '@agent-device/host-kit/transport'; +import { sleep } from '@agent-device/host-kit/retry'; import { AppError } from '@agent-device/kernel/errors'; import { DAEMON_HTTP_INSTANCE_HEADER, @@ -36,6 +39,64 @@ function sendWithStaleInstance(port: number, timeoutMs: number) { ); } +test('auto health probing reserves time for a healthy fallback when HTTP hangs', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const httpServer = http.createServer(() => {}); + const socketServer = net.createServer((socket) => socket.on('error', () => {})); + try { + const httpPort = await listenOnLoopback(httpServer); + const port = await listenOnLoopback(socketServer); + assert.equal( + await canConnect({ token: 'secret', pid: 1, transport: 'http', httpPort, port }, 'auto', 120), + true, + ); + } finally { + await closeLoopbackServer(httpServer); + await closeLoopbackServer(socketServer); + } +}); + +test('the health deadline includes requester loading and forbids a late request', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + let requests = 0; + const server = http.createServer((_req, res) => { + requests += 1; + res.end('{}'); + }); + let release!: () => void; + const blocked = new Promise((resolve) => { + release = resolve; + }); + const actualLoad = hostTransport.loadNodeHttpRequester; + const load = vi + .spyOn(hostTransport, 'loadNodeHttpRequester') + .mockImplementation(async (protocol) => { + await blocked; + return actualLoad(protocol); + }); + let probing: Promise | undefined; + try { + const httpPort = await listenOnLoopback(server); + let settled = false; + probing = canConnect({ token: 'secret', pid: 1, httpPort }, 'http', 40).then((reachable) => { + settled = true; + return reachable; + }); + await sleep(90); + assert.equal(settled, true, 'loading must not extend the probe deadline'); + assert.equal(await probing, false); + release(); + await blocked; + await sleep(10); + assert.equal(requests, 0, 'a timed-out loader must not open a request later'); + } finally { + release(); + await probing; + load.mockRestore(); + await closeLoopbackServer(server); + } +}); + test('persistent remote client caches health and retries a refused stale instance before dispatch', async (t) => { if (await skipWhenLoopbackUnavailable(t)) return; const paths: string[] = []; diff --git a/src/daemon-client/__tests__/daemon-client.test.ts b/src/daemon-client/__tests__/daemon-client.test.ts index f579a3c4a5..cca31e8a26 100644 --- a/src/daemon-client/__tests__/daemon-client.test.ts +++ b/src/daemon-client/__tests__/daemon-client.test.ts @@ -1,5 +1,5 @@ import type { RequestProgressEvent } from '@agent-device/contracts/progress'; -import { test, vi } from 'vitest'; +import { test } from 'vitest'; import assert from 'node:assert/strict'; import http from 'node:http'; import net from 'node:net'; @@ -11,57 +11,18 @@ import { listenOnLoopback, supportsLoopbackBind, } from '../../__tests__/test-utils/loopback.ts'; -import { runCmdBackground } from '@agent-device/host-kit/command'; -import { - isProcessAlive, - readProcessCommand, - readProcessStartTime, - waitForProcessExit, -} from '@agent-device/host-kit/process'; +import { readProcessStartTime } from '@agent-device/host-kit/process'; import { sendToDaemon } from '../daemon-client.ts'; import { currentDaemonCodeSignature } from '../../__tests__/test-utils/daemon-http-fixture.ts'; import { computeDaemonCodeSignature } from '@agent-device/host-kit/code-signature'; import { downloadRemoteArtifact } from '../../remote/daemon-artifacts.ts'; -import { - cleanupFailedDaemonStartupMetadata, - resolveDaemonStartupHint, -} from '../daemon-client-metadata.ts'; +import { resolveDaemonStartupHint } from '../daemon-client-metadata.ts'; import { canConnectSocket } from '../daemon-client-transport.ts'; import { DAEMON_RPC_PROTOCOL_VERSION } from '@agent-device/contracts/daemon-http'; import { resolveDaemonPaths } from '../../daemon-resolution.ts'; import { readVersion } from '@agent-device/host-kit/version'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; -// readProcessStartTime/readProcessCommand shell out to `ps` with a 1s -// timeout (see host-process.ts). isAgentDeviceDaemonProcess re-reads both for -// every liveness check, so a spawned-daemon fixture that is proven live once -// (a real read, right after the process starts) can still be misclassified -// as dead later if a *subsequent* `ps` call happens to miss its deadline -// under full-suite CPU contention. mockReadProcessStartTime/mockReadProcessCommand -// default to `undefined`, which falls through to the real implementation for -// every pid in every test in this file; only the one test below that needs a -// stable answer for its spawned pid configures an override, and clears it -// afterward. -const { mockReadProcessStartTime, mockReadProcessCommand } = vi.hoisted(() => ({ - mockReadProcessStartTime: vi.fn<(pid: number) => string | null | undefined>(), - mockReadProcessCommand: vi.fn<(pid: number) => string | null | undefined>(), -})); - -vi.mock('@agent-device/host-kit/process', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - readProcessStartTime: (pid: number) => { - const overridden = mockReadProcessStartTime(pid); - return overridden !== undefined ? overridden : actual.readProcessStartTime(pid); - }, - readProcessCommand: (pid: number) => { - const overridden = mockReadProcessCommand(pid); - return overridden !== undefined ? overridden : actual.readProcessCommand(pid); - }, - }; -}); - type MockHttpResponse = EventEmitter & { headers?: Record; statusCode?: number; @@ -179,181 +140,19 @@ function writeCurrentDaemonInfo( ); } -test('resolveDaemonStartupHint prefers stale lock guidance when lock exists without info', () => { - const hint = resolveDaemonStartupHint({ hasInfo: false, hasLock: true }); - assert.match(hint, /daemon\.lock/i); - assert.match(hint, /automatically/i); - assert.match(hint, /rm -f '.+daemon\.json' '.+daemon\.lock'/); -}); - -test('resolveDaemonStartupHint covers stale info+lock pair', () => { - const hint = resolveDaemonStartupHint({ hasInfo: true, hasLock: true }); - assert.match(hint, /daemon\.json/i); - assert.match(hint, /daemon\.lock/i); - assert.match(hint, /rm -f '.+daemon\.json' '.+daemon\.lock'/); -}); - -test('resolveDaemonStartupHint falls back to daemon.json guidance', () => { - const hint = resolveDaemonStartupHint({ hasInfo: true, hasLock: false }); - assert.match(hint, /daemon\.json/i); - assert.match(hint, /rm -f '.+daemon\.json' '.+daemon\.lock'/); -}); - -test('resolveDaemonStartupHint includes configured state directory paths', () => { - const paths = resolveDaemonPaths('/tmp/ad-custom-state'); - const hint = resolveDaemonStartupHint({ hasInfo: false, hasLock: true }, paths); - assert.match(hint, /\/tmp\/ad-custom-state\/daemon\.lock/); - assert.match(hint, /\/tmp\/ad-custom-state\/daemon\.json/); - assert.match( - hint, - /rm -f '\/tmp\/ad-custom-state\/daemon\.json' '\/tmp\/ad-custom-state\/daemon\.lock'/, - ); -}); - -test('resolveDaemonStartupHint shell-quotes cleanup paths', () => { +test('startup recovery guidance retains configured paths and requires stopping every user', () => { const paths = resolveDaemonPaths("/tmp/ad custom's state"); - const hint = resolveDaemonStartupHint({ hasInfo: true, hasLock: true }, paths); - assert.match( - hint, - /rm -f '\/tmp\/ad custom'\\''s state\/daemon\.json' '\/tmp\/ad custom'\\''s state\/daemon\.lock'/, - ); -}); - -test('cleanupFailedDaemonStartupMetadata removes partial startup metadata', async () => { - const stateDir = mkdtempForTestSync('agent-device-daemon-cleanup-'); - const paths = resolveDaemonPaths(stateDir); - try { - fs.mkdirSync(paths.baseDir, { recursive: true }); - fs.writeFileSync(paths.infoPath, '{"invalid":true}\n', 'utf8'); - fs.writeFileSync(paths.lockPath, 'not-json\n', 'utf8'); - - const result = await cleanupFailedDaemonStartupMetadata(paths, 'startup_timeout'); - - assert.deepEqual(result, { - reason: 'startup_timeout', - removedInfo: true, - removedLock: true, - stoppedInfoProcess: false, - stoppedLockProcess: false, - }); - assert.equal(fs.existsSync(paths.infoPath), false); - assert.equal(fs.existsSync(paths.lockPath), false); - } finally { - fs.rmSync(stateDir, { recursive: true, force: true }); - } -}); - -test('cleanupFailedDaemonStartupMetadata retains live startup daemon on timeout', async (t) => { - const stateDir = mkdtempForTestSync('agent-device-daemon-live-cleanup-'); - const root = mkdtempForTestSync('agent-device-live-daemon-'); - const daemonDir = path.join(root, 'agent-device', 'dist', 'src', 'internal'); - const daemonScriptPath = path.join(daemonDir, 'daemon.js'); - fs.mkdirSync(daemonDir, { recursive: true }); - fs.writeFileSync(daemonScriptPath, 'setInterval(() => {}, 1000);\n', 'utf8'); - const daemonProcess = runCmdBackground(process.execPath, [daemonScriptPath], { - stdio: 'ignore', - allowFailure: true, - captureOutput: false, - }); - void daemonProcess.wait.catch(() => {}); - const pid = daemonProcess.child.pid; - assert.ok(pid, 'spawned child should have a pid'); - - try { - await new Promise((resolve) => setTimeout(resolve, 50)); - // Read the spawned daemon's real identity once (ground truth: it is - // genuinely alive, with this real start time and command line), then - // pin readProcessStartTime/readProcessCommand to keep returning these - // same proven-real values for this pid. isAgentDeviceDaemonProcess reads - // both again internally on every call inside cleanupFailedDaemonStartupMetadata; - // without pinning, a second real `ps` call could miss its 1s timeout - // under load and misclassify this genuinely-live daemon as dead. - const processStartTime = readProcessStartTime(pid) ?? undefined; - const command = readProcessCommand(pid); - if (command === null || processStartTime === undefined) { - t.skip('process command/start inspection is unavailable in this environment'); - return; - } - mockReadProcessStartTime.mockImplementation((queriedPid: number) => - queriedPid === pid ? processStartTime : undefined, - ); - mockReadProcessCommand.mockImplementation((queriedPid: number) => - queriedPid === pid ? command : undefined, - ); - - const paths = resolveDaemonPaths(stateDir); - fs.mkdirSync(paths.baseDir, { recursive: true }); - fs.writeFileSync( - paths.infoPath, - `${JSON.stringify({ - token: 'startup-secret', - port: 65530, - transport: 'socket', - pid, - processStartTime, - })}\n`, - 'utf8', - ); - fs.writeFileSync( - paths.lockPath, - `${JSON.stringify({ pid, processStartTime, startedAt: Date.now() })}\n`, - 'utf8', - ); - - const result = await cleanupFailedDaemonStartupMetadata(paths, 'startup_timeout', { - stopLiveProcesses: false, - }); - - assert.equal(result.retainedInfoProcess, true); - assert.equal(result.retainedLockProcess, true); - assert.equal(result.removedInfo, false); - assert.equal(result.removedLock, false); - assert.equal(isProcessAlive(pid), true); - assert.equal(fs.existsSync(paths.infoPath), true); - assert.equal(fs.existsSync(paths.lockPath), true); - } finally { - mockReadProcessStartTime.mockReset(); - mockReadProcessCommand.mockReset(); - if (isProcessAlive(pid)) { - process.kill(pid, 'SIGKILL'); - await waitForProcessExit(pid, 1_500); - } - fs.rmSync(stateDir, { recursive: true, force: true }); - fs.rmSync(root, { recursive: true, force: true }); - } -}); - -test('cleanupFailedDaemonStartupMetadata removes stale daemon metadata on timeout', async () => { - const stateDir = mkdtempForTestSync('agent-device-daemon-stale-cleanup-'); - const paths = resolveDaemonPaths(stateDir); - try { - fs.mkdirSync(paths.baseDir, { recursive: true }); - fs.writeFileSync( - paths.infoPath, - `${JSON.stringify({ - token: 'startup-secret', - port: 65530, - transport: 'socket', - pid: 999_999, - })}\n`, - 'utf8', - ); - fs.writeFileSync( - paths.lockPath, - `${JSON.stringify({ pid: 999_999, startedAt: Date.now() })}\n`, - 'utf8', - ); - - const result = await cleanupFailedDaemonStartupMetadata(paths, 'startup_timeout', { - stopLiveProcesses: false, - }); - - assert.equal(result.removedInfo, true); - assert.equal(result.removedLock, true); - assert.equal(fs.existsSync(paths.infoPath), false); - assert.equal(fs.existsSync(paths.lockPath), false); - } finally { - fs.rmSync(stateDir, { recursive: true, force: true }); + for (const state of [ + { hasInfo: false, hasLock: true }, + { hasInfo: true, hasLock: true }, + { hasInfo: true, hasLock: false }, + { hasInfo: false, hasLock: false }, + ]) { + const hint = resolveDaemonStartupHint(state, paths); + if (state.hasInfo) assert.ok(hint.includes(paths.infoPath)); + if (state.hasLock) assert.ok(hint.includes(paths.lockPath)); + assert.match(hint, /stop all older clients and daemons/); + assert.doesNotMatch(hint, /rm -f/); } }); diff --git a/src/daemon-client/daemon-client-lifecycle.ts b/src/daemon-client/daemon-client-lifecycle.ts index 43a6556781..1d76d43ecf 100644 --- a/src/daemon-client/daemon-client-lifecycle.ts +++ b/src/daemon-client/daemon-client-lifecycle.ts @@ -1,17 +1,26 @@ import fs from 'node:fs'; import net from 'node:net'; -import os from 'node:os'; -import path from 'node:path'; -import { AppError, normalizeError } from '@agent-device/kernel/errors'; +import { AppError, normalizeError, type NormalizedError } from '@agent-device/kernel/errors'; import { readReplayDivergenceResume } from '@agent-device/ad-replay/divergence'; import type { DaemonRequest, DaemonResponse } from '../daemon/daemon-request.ts'; -import { runCmdDetachedMonitored, type ExecDetachedExit } from '@agent-device/host-kit/command'; +import { type ExecDetachedExit } from '@agent-device/host-kit/command'; import { shellQuoteIfNeeded } from '@agent-device/kernel/device-shell'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; -import { isProcessAlive, readProcessStartTime } from '@agent-device/host-kit/process'; +import { isProcessAlive } from '@agent-device/host-kit/process'; import { sleep } from '@agent-device/host-kit/retry'; +import { inspectProcessLock, type ProcessLockInspection } from '@agent-device/host-kit/file'; -import { findUnrecoveredRepairCommitFailure } from '../session-repair-tombstone.ts'; +import type { findUnrecoveredRepairCommitFailure } from '../session-repair-tombstone.ts'; +import { + DAEMON_STARTUP_EXIT_CODES, + createOwnedReplayStateDir, + recoverAbandonedDaemonRegistration, + type DaemonRetirementResult, + launchDaemonProcess, + stopAndRetireDaemon, + type OwnedReplayStateDir, + type DaemonStartupLaunch, +} from '../daemon-registration-owner.ts'; import { resolveDaemonPaths, resolveDaemonServerMode, @@ -28,19 +37,11 @@ import { import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; import { - cleanupFailedDaemonStartupMetadata, - cleanupStaleDaemonLockIfSafe, getDaemonMetadataState, - isDaemonLockHeldByAnotherDaemon, isRemoteDaemon, readDaemonInfo, - recoverDaemonLockHolder, - removeDaemonInfo, - removeDaemonLock, resolveDaemonStartupHint, - stopDaemonProcessForTakeover, type DaemonInfo, - type DaemonStartupCleanupResult, } from './daemon-client-metadata.ts'; import { canConnect, @@ -52,7 +53,7 @@ export type DaemonClientSettings = { paths: DaemonPaths; transportPreference: DaemonTransportPreference; serverMode: DaemonServerMode; - ownedStateDir?: boolean; + ownedStateDir?: OwnedReplayStateDir; remoteBaseUrl?: string; remoteAuthToken?: string; }; @@ -62,19 +63,14 @@ export type EnsuredDaemon = { startedByClient: boolean; }; -type DaemonStartupLaunch = { - pid: number; - /** The launched process's start time, so a reused pid is never taken for it. */ - startTime?: string; - exited: Promise; -}; - type DaemonStartupWaitResult = | { kind: 'ready'; daemon: EnsuredDaemon } | { kind: 'early_exit'; exit: ExecDetachedExit } - | { kind: 'timeout' }; + | { kind: 'unproven'; error: AppError } + | { kind: 'retry' | 'timeout' }; const DAEMON_STARTUP_TIMEOUT_MS = 15_000; +const MINIMUM_DAEMON_TAKEOVER_BUDGET_MS = 5_000; const LIVE_DAEMON_PROBE_RETRIES = 3; const LIVE_DAEMON_PROBE_RETRY_DELAY_MS = 200; const DAEMON_STARTUP_ATTEMPTS = 2; @@ -91,10 +87,11 @@ export function resolveClientSettings( const explicitStateDir = resolveExplicitStateDir(req); const remote = resolveRemoteClientSettings(req, suppliedAuthToken); const transport = resolveTransportClientSettings(req, remote.remoteBaseUrl); - const ownedStateDir = shouldUseOwnedReplayStateDir(req, explicitStateDir, remote.rawBaseUrl); - const stateDir = ownedStateDir ? createOwnedReplayStateDir() : explicitStateDir; + const ownedStateDir = shouldUseOwnedReplayStateDir(req, explicitStateDir, remote.rawBaseUrl) + ? createOwnedReplayStateDir() + : undefined; return { - paths: resolveDaemonPaths(stateDir), + paths: ownedStateDir?.paths ?? resolveDaemonPaths(explicitStateDir), transportPreference: transport.preference, serverMode: transport.serverMode, ownedStateDir, @@ -153,10 +150,6 @@ function shouldUseOwnedReplayStateDir( return isOneShotReplayCommand(req.command) && !explicitStateDir && !rawRemoteBaseUrl; } -function createOwnedReplayStateDir(): string { - return fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-daemon-')); -} - export async function ensureDaemon(settings: DaemonClientSettings): Promise { if (settings.remoteBaseUrl) { return await ensureRemoteDaemon(settings); @@ -172,7 +165,6 @@ async function ensureLocalDaemon(settings: DaemonClientSettings): Promise { +async function readReusableLocalDaemon( + settings: DaemonClientSettings, + options: { deadline?: number; waitForLiveStartup?: boolean } = {}, +): Promise { + const { deadline } = options; + const inspection = inspectProcessLock(settings.paths.lockPath); + if (inspection.state === 'unproven') throw daemonRegistrationUnprovenError(settings, inspection); const existing = readDaemonInfo(settings.paths.infoPath); if (!existing) return null; + if (!registrationAllowsDaemonObservation(inspection, existing)) return null; + if ( + options.waitForLiveStartup && + isProcessAlive(existing.pid) && + !(await canConnect(existing, 'auto', remainingStartupBudget(deadline))) + ) + return null; const decision = await resolveDaemonTakeover(existing, { - onClientTransport: () => canReachReusableDaemon(existing, settings.transportPreference), - onAnyAdvertisedTransport: () => canReachReusableDaemon(existing, 'auto'), + onClientTransport: () => + canReachReusableDaemon(existing, settings.transportPreference, deadline), + onAnyAdvertisedTransport: () => canReachReusableDaemon(existing, 'auto', deadline), }); if (decision.kind === 'reuse') return existing; if (decision.kind === 'refuseNewer') { throw newerDaemonRefusedError(existing, decision, settings.paths.baseDir); } + if (remainingStartupBudget(deadline) < MINIMUM_DAEMON_TAKEOVER_BUDGET_MS) return null; emitDaemonTakeoverNotice(existing, decision.reason, settings.paths.baseDir); - await stopDaemonProcessForTakeover(existing); - removeDaemonInfo(settings.paths.infoPath); + await retireDaemonForTakeover(existing, settings.paths); return null; } +function registrationAllowsDaemonObservation( + inspection: ProcessLockInspection, + info: DaemonInfo, +): boolean { + return ( + inspection.state === 'absent' || + (inspection.state === 'held' && + inspection.owner.pid === info.pid && + inspection.owner.startTime === (info.processStartTime ?? null)) + ); +} + +function daemonRegistrationUnprovenError( + settings: DaemonClientSettings, + inspection?: ProcessLockInspection, +): AppError { + return new AppError('COMMAND_FAILED', 'Daemon registration ownership could not be verified.', { + reason: 'daemon_registration_unproven', + inspection, + stateDir: settings.paths.baseDir, + hint: resolveDaemonStartupHint(getDaemonMetadataState(settings.paths), settings.paths), + }); +} + +async function retireDaemonForTakeover(existing: DaemonInfo, paths: DaemonPaths): Promise { + const retirement = await stopAndRetireDaemon({ + paths: paths, + observed: { pid: existing.pid, startTime: existing.processStartTime ?? null }, + mode: 'graceful', + }); + if (retirement.status === 'retained') { + throw new AppError('COMMAND_FAILED', 'Daemon replacement could not be confirmed.', { + reason: 'daemon_retirement_unconfirmed', + retirement, + hint: + retirement.error?.hint ?? resolveDaemonStartupHint(getDaemonMetadataState(paths), paths), + }); + } +} + /** * A daemon whose pid is still alive is probed again before it can be judged unreachable. A probe's * budget is wall-clock time on this client's event loop, so a client that stalls past it (a large @@ -226,12 +272,14 @@ async function readReusableLocalDaemon(settings: DaemonClientSettings): Promise< async function canReachReusableDaemon( info: DaemonInfo, preference: DaemonTransportPreference, + deadline?: number, ): Promise { - if (await canConnectReusableDaemon(info, preference)) return true; + if (await canConnectReusableDaemon(info, preference, deadline)) return true; for (let retry = 1; retry <= LIVE_DAEMON_PROBE_RETRIES; retry += 1) { - if (!isProcessAlive(info.pid)) return false; - await sleep(LIVE_DAEMON_PROBE_RETRY_DELAY_MS); - if (await canConnectReusableDaemon(info, preference)) { + if (!isProcessAlive(info.pid) || (deadline !== undefined && Date.now() >= deadline)) + return false; + await sleep(Math.min(LIVE_DAEMON_PROBE_RETRY_DELAY_MS, remainingStartupBudget(deadline))); + if (await canConnectReusableDaemon(info, preference, deadline)) { emitDiagnostic({ level: 'warn', phase: 'daemon_probe_recovered', @@ -267,9 +315,10 @@ async function assertDaemonPolicyMatches(existing: DaemonInfo, stateDir: string) async function canConnectReusableDaemon( info: DaemonInfo, preference: DaemonTransportPreference, + deadline?: number, ): Promise { try { - return await canConnect(info, preference); + return await canConnect(info, preference, remainingStartupBudget(deadline)); } catch (error) { if (isDaemonTransportUnavailableError(error)) return false; throw error; @@ -303,65 +352,29 @@ function emitDaemonTakeoverNotice(info: DaemonInfo, reason: string, stateDir: st } } -async function startLocalDaemon(settings: DaemonClientSettings): Promise { - let lockRecoveryCount = 0; - const cleanupResults: DaemonStartupCleanupResult[] = []; - let startError: string | undefined; - let daemonProcess: ExecDetachedExit | { pid: number } | undefined; - for (let attempt = 1; attempt <= DAEMON_STARTUP_ATTEMPTS; attempt += 1) { - let launch: DaemonStartupLaunch; - try { - launch = startDaemon(settings); - daemonProcess = { pid: launch.pid }; - } catch (error) { - startError = error instanceof Error ? error.message : String(error); - cleanupResults.push(await cleanupFailedDaemonStartupMetadata(settings.paths, 'start_error')); - if (attempt < DAEMON_STARTUP_ATTEMPTS) { - await sleep(150); - continue; - } - break; - } - - const startup = await waitForDaemonStartup(DAEMON_STARTUP_TIMEOUT_MS, settings, launch); - if (startup.kind === 'ready') return startup.daemon; - if (startup.kind === 'early_exit') { - daemonProcess = startup.exit; - startError = describeDaemonEarlyExit(startup.exit); - cleanupResults.push(await cleanupFailedDaemonStartupMetadata(settings.paths, 'start_error')); - if (attempt < DAEMON_STARTUP_ATTEMPTS) { - await sleep(150); - continue; - } - break; - } - - if (await recoverDaemonLockHolder(settings.paths)) { - lockRecoveryCount += 1; - continue; - } - - const metadataState = getDaemonMetadataState(settings.paths); - const hasAnotherAttempt = attempt < DAEMON_STARTUP_ATTEMPTS; - const cleanup = await cleanupFailedDaemonStartupMetadata(settings.paths, 'startup_timeout', { - stopLiveProcesses: false, - }); - cleanupResults.push(cleanup); - if (cleanup.retainedInfoProcess || cleanup.retainedLockProcess) { - const extended = await waitForDaemonStartup(DAEMON_STARTUP_TIMEOUT_MS, settings, launch); - if (extended.kind === 'ready') return extended.daemon; - if (extended.kind === 'early_exit') { - daemonProcess = extended.exit; - startError = describeDaemonEarlyExit(extended.exit); - } - break; - } - if (!hasAnotherAttempt) break; +type FailedDaemonStartup = { + cleanup?: DaemonRetirementResult; + startError?: string; + startupError?: NormalizedError; + daemonProcess?: ExecDetachedExit | { pid: number }; + retry: boolean; +}; - // Detached daemon startup can race on busy CI hosts; retry when no metadata exists yet. - if (!metadataState.hasInfo && !metadataState.hasLock) await sleep(150); +async function startLocalDaemon(settings: DaemonClientSettings): Promise { + const deadline = Date.now() + DAEMON_STARTUP_TIMEOUT_MS; + const cleanupResults: DaemonRetirementResult[] = []; + let failure: FailedDaemonStartup | undefined; + let attempts = 0; + while (attempts < DAEMON_STARTUP_ATTEMPTS && Date.now() < deadline) { + attempts += 1; + const result = await attemptLocalDaemonStartup(settings, deadline); + if ('daemon' in result) return result.daemon; + failure = result; + if (result.cleanup) cleanupResults.push(result.cleanup); + if (!result.retry) break; + await sleep(Math.min(150, Math.max(0, deadline - Date.now()))); } - + const { startError, startupError, daemonProcess }: Partial = failure ?? {}; const state = getDaemonMetadataState(settings.paths); const daemonLogTail = readRecentLogTail(settings.paths.logPath); throw new AppError('COMMAND_FAILED', 'Failed to start daemon', { @@ -371,10 +384,10 @@ async function startLocalDaemon(settings: DaemonClientSettings): Promise { + let launch: DaemonStartupLaunch; + try { + launch = startDaemon(settings); + } catch (error) { + const cleanup = await recoverAbandonedDaemonRegistration({ + paths: settings.paths, + observed: null, + lockTimeoutMs: 0, + }); + return { + cleanup, + startError: normalizeError(error).message, + retry: cleanup.status !== 'retained', + }; + } + const startup = await waitForDaemonStartup(deadline, settings, launch); + if (startup.kind === 'ready') return { daemon: startup.daemon }; + if (startup.kind === 'retry') return { retry: true }; + if (startup.kind === 'unproven') { + const startupError = normalizeError(startup.error); + return { + retry: false, + startError: startupError.message, + startupError, + daemonProcess: { pid: launch.pid }, + }; + } + const { cleanup, joined } = await retireStartupAttempt(settings, launch, deadline); + const available = isRegistrationAvailable(inspectProcessLock(settings.paths.lockPath)); + return { + cleanup, + retry: joined && startup.kind === 'early_exit' && available, + startError: startup.kind === 'early_exit' ? describeDaemonEarlyExit(startup.exit) : undefined, + daemonProcess: startup.kind === 'early_exit' ? startup.exit : { pid: launch.pid }, + }; +} + +async function retireStartupAttempt( + settings: DaemonClientSettings, + launch: DaemonStartupLaunch, + deadline: number, + ownedStateDir?: OwnedReplayStateDir, +): Promise<{ cleanup: DaemonRetirementResult; joined: boolean }> { + const cleanup = await stopAndRetireDaemon({ + paths: settings.paths, + observed: { pid: launch.pid, startTime: launch.startTime ?? null }, + mode: 'graceful', + ownedStateDir, + termTimeoutMs: Math.min(3_000, remainingStartupBudget(deadline)), + killTimeoutMs: 1_000, + lockTimeoutMs: 0, + }); + const joined = await joinStartup(launch); + return { cleanup, joined }; +} + +async function joinStartup(launch: DaemonStartupLaunch): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + launch.exited.then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), 1_000); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +function remainingStartupBudget(deadline?: number): number { + return deadline === undefined ? Number.POSITIVE_INFINITY : Math.max(0, deadline - Date.now()); +} + +function isRegistrationAvailable(inspection: ProcessLockInspection): boolean { + return ( + inspection.state === 'absent' || + (inspection.state === 'held' && + (inspection.liveness === 'owner-process-dead' || + inspection.liveness === 'owner-process-reused')) + ); +} + /** * ADR 0012 decision 6 (BLOCKER 2, third follow-up): a one-shot repair * (`replay --save-script`) that COMPLETES without diverging returns SUCCESS * here — the actual healed-script COMMIT is deferred to daemon teardown * (`finalizeRepairTeardown`, run inside the daemon process's own shutdown - * handler, triggered by `stopDaemonProcessForTakeover` below). If that + * handler, triggered by `stopAndRetireDaemon`). If that * deferred commit then FAILS, the daemon leaves a `REPAIR_COMMIT_FAILED` * tombstone in this owned state dir — the only surviving record of the * failure, since the daemon process (and its in-memory session) is gone by @@ -435,49 +535,44 @@ export async function cleanupDaemonAfterRequest( return response; } - const result = { - pid: daemon.info.pid, - removedInfo: false, - removedLock: false, - removedStateDir: false, - error: undefined as string | undefined, - }; - let surfacedResponse = response; - - try { - await stopDaemonProcessForTakeover(daemon.info); - } catch (error) { - result.error = error instanceof Error ? error.message : String(error); - } finally { - const infoExists = fs.existsSync(settings.paths.infoPath); - removeDaemonInfo(settings.paths.infoPath); - result.removedInfo = infoExists && !fs.existsSync(settings.paths.infoPath); - - const lockExists = fs.existsSync(settings.paths.lockPath); - removeDaemonLock(settings.paths.lockPath); - result.removedLock = lockExists && !fs.existsSync(settings.paths.lockPath); - - if (settings.ownedStateDir) { - // `stopDaemonProcessForTakeover` above waits for the (real) daemon - // process to actually exit, which only happens AFTER its shutdown - // handler finishes `finalizeRepairTeardown` for every session — so by - // now any commit-failure tombstone it would leave is already on disk. - const unrecovered = findUnrecoveredRepairCommitFailure(settings.paths.sessionsDir); - if (unrecovered) { - surfacedResponse = surfaceUnrecoveredRepairCommitFailure(response, unrecovered); - } else { - fs.rmSync(settings.paths.baseDir, { recursive: true, force: true }); - result.removedStateDir = !fs.existsSync(settings.paths.baseDir); - } - } - } - + const result = await stopAndRetireDaemon({ + paths: settings.paths, + observed: { pid: daemon.info.pid, startTime: daemon.info.processStartTime ?? null }, + mode: 'graceful', + ownedStateDir: settings.ownedStateDir, + }); emitDiagnostic({ - level: result.error ? 'warn' : 'info', + level: result.status === 'retained' ? 'warn' : 'info', phase: 'daemon_replay_cleanup', - data: result, + data: { pid: daemon.info.pid, ...result }, }); - return surfacedResponse; + if (result.status !== 'absent' && result.repairCommitFailure) { + return surfaceUnrecoveredRepairCommitFailure( + response, + result.repairCommitFailure, + result.status === 'retained' ? result.error : undefined, + ); + } + if (result.status === 'retained' && response?.ok) { + return { + ok: false, + error: normalizeError( + new AppError( + 'COMMAND_FAILED', + 'Replay completed, but daemon cleanup could not be confirmed.', + { + reason: 'daemon_retirement_unconfirmed', + retirement: result, + stateDir: settings.paths.baseDir, + hint: + result.error?.hint ?? + `State and diagnostics were retained at ${settings.paths.baseDir}. Resolve the reported cleanup failure before retrying.`, + }, + ), + ), + }; + } + return response; } /** @@ -498,6 +593,7 @@ export async function cleanupDaemonAfterRequest( function surfaceUnrecoveredRepairCommitFailure( response: DaemonResponse | undefined, unrecovered: NonNullable>, + cleanupFailure?: NormalizedError, ): DaemonResponse { if (response && !response.ok) return response; const { sessionName, tombstone } = unrecovered; @@ -507,7 +603,16 @@ function surfaceUnrecoveredRepairCommitFailure( const message = `The repair transaction for session "${sessionName}" completed, but committing its ` + `healed script failed at teardown: ${tombstone.commitFailure.message}. ${reRun}.`; - return { ok: false, error: normalizeError(new AppError('REPAIR_COMMIT_FAILED', message)) }; + return { + ok: false, + error: normalizeError( + new AppError( + 'REPAIR_COMMIT_FAILED', + message, + cleanupFailure ? { cleanupFailure } : undefined, + ), + ), + }; } /** @@ -524,7 +629,7 @@ function surfaceUnrecoveredRepairCommitFailure( * `resume.allowed` (plan-resumability): a held divergence with `allowed: false` * still holds the session so the agent can inspect and `close` cleanly. */ -export function isHeldRepairDivergence(response: DaemonResponse | undefined): boolean { +function isHeldRepairDivergence(response: DaemonResponse | undefined): boolean { if (!response || response.ok) return false; if (response.error.code !== 'REPLAY_DIVERGENCE') return false; const resume = readReplayDivergenceResume(response.error.details?.divergence); @@ -539,7 +644,7 @@ export function isHeldRepairDivergence(response: DaemonResponse | undefined): bo * selector-miss's own guidance) so the agent's next command knows to target * the SAME daemon instead of resolving to the default one. */ -export function attachRepairSessionAddressHint( +function attachRepairSessionAddressHint( response: Extract, stateDir: string, ): Extract { @@ -571,7 +676,7 @@ function isOneShotReplayCommand(command: string | undefined): boolean { * anyway, but the explicit command check keeps that carve-out a decision * rather than an accident of the response shape. */ -export function isActiveReplaySessionResponse( +function isActiveReplaySessionResponse( req: Omit, response: DaemonResponse | undefined, ): boolean { @@ -628,36 +733,102 @@ export function attachActiveSessionAddressHint( } async function waitForDaemonStartup( - timeoutMs: number, + deadline: number, settings: DaemonClientSettings, launch: DaemonStartupLaunch, ): Promise { - const start = Date.now(); let earlyExit: ExecDetachedExit | undefined; void launch.exited.then((exit) => { earlyExit = exit; }); - - while (Date.now() - start < timeoutMs) { - const info = readDaemonInfo(settings.paths.infoPath); - if (info && (await canConnect(info, settings.transportPreference))) { - if (isLaunchedDaemon(info, launch)) { - return { kind: 'ready', daemon: { info, startedByClient: true } }; - } - // Another client's daemon won the start: adopt it only as a reusable daemon would be. An - // incompatible one is replaced, and this wait then sees its own daemon's early exit. - const winner = await readReusableLocalDaemon(settings); - if (winner) return { kind: 'ready', daemon: { info: winner, startedByClient: false } }; + while (Date.now() < deadline) { + if (earlyExit) { + const kind = classifyDaemonStartupExit(earlyExit); + if (kind === 'unproven') + return { kind: 'unproven', error: daemonRegistrationUnprovenError(settings) }; + if (kind === 'failed') return { kind: 'early_exit', exit: earlyExit }; + const contender = await observeContendingDaemon(settings, deadline); + if (contender) return contender; + } else { + const info = await readReadyLaunchedDaemon(settings, launch, deadline); + if (info && !earlyExit) return { kind: 'ready', daemon: { info, startedByClient: true } }; } - // A daemon that lost the startup lock exits cleanly; the daemon that won it is still starting. - if (earlyExit && !isDaemonLockHeldByAnotherDaemon(settings.paths, earlyExit.pid)) { - return { kind: 'early_exit', exit: earlyExit }; - } - await sleep(100); + await sleep(Math.min(100, remainingStartupBudget(deadline))); } return { kind: 'timeout' }; } +function classifyDaemonStartupExit(exit: ExecDetachedExit): 'busy' | 'unproven' | 'failed' { + if (exit.error || exit.signal) return 'failed'; + switch (exit.exitCode) { + case DAEMON_STARTUP_EXIT_CODES.busy: + return 'busy'; + case DAEMON_STARTUP_EXIT_CODES.unproven: + return 'unproven'; + default: + return 'failed'; + } +} + +async function observeContendingDaemon( + settings: DaemonClientSettings, + deadline: number, +): Promise { + let winner: DaemonInfo | null; + try { + winner = await readReusableLocalDaemon(settings, { deadline, waitForLiveStartup: true }); + } catch (error) { + if (error instanceof AppError && error.details?.reason === 'daemon_registration_unproven') + return { kind: 'unproven', error }; + throw error; + } + if (Date.now() >= deadline) return null; + if (winner) return { kind: 'ready', daemon: { info: winner, startedByClient: false } }; + const inspection = inspectProcessLock(settings.paths.lockPath); + if (inspection.state === 'unproven') + return { kind: 'unproven', error: daemonRegistrationUnprovenError(settings, inspection) }; + return isRegistrationAvailable(inspection) ? { kind: 'retry' } : null; +} + +async function readReadyLaunchedDaemon( + settings: DaemonClientSettings, + launch: DaemonStartupLaunch, + deadline: number, +): Promise { + const info = readDaemonInfo(settings.paths.infoPath); + if (!info || !isLaunchedDaemon(info, launch)) return null; + try { + return (await canConnect( + info, + settings.transportPreference, + remainingStartupBudget(deadline), + )) && Date.now() < deadline + ? info + : null; + } catch (error) { + const { cleanup, joined } = await retireStartupAttempt( + settings, + launch, + deadline, + settings.ownedStateDir, + ); + emitDiagnostic({ + level: 'warn', + phase: 'daemon_startup_observation_failed', + data: { stateDir: settings.paths.baseDir, cleanup, joined, error: normalizeError(error) }, + }); + if (error instanceof AppError) { + error.details = { + ...error.details, + stateDir: settings.paths.baseDir, + cleanupResults: [cleanup], + startupJoined: joined, + }; + } + throw error; + } +} + /** Whether `info` names the daemon process this client launched: same pid and start time. */ function isLaunchedDaemon(info: DaemonInfo, launch: DaemonStartupLaunch): boolean { return ( @@ -669,28 +840,14 @@ function isLaunchedDaemon(info: DaemonInfo, launch: DaemonStartupLaunch): boolea function startDaemon(settings: DaemonClientSettings): DaemonStartupLaunch { const launchSpec = resolveDaemonLaunchSpec(); - const args = launchSpec.useSrc - ? ['--experimental-strip-types', launchSpec.srcPath] - : [launchSpec.distPath]; - const env = { - ...process.env, - AGENT_DEVICE_STATE_DIR: settings.paths.baseDir, - AGENT_DEVICE_DAEMON_SERVER_MODE: settings.serverMode, - }; - - fs.mkdirSync(settings.paths.baseDir, { recursive: true }); - const stdoutFd = fs.openSync(settings.paths.logPath, 'a'); - const stderrFd = fs.openSync(settings.paths.logPath, 'a'); - try { - const launched = runCmdDetachedMonitored(process.execPath, args, { - env, - stdio: ['ignore', stdoutFd, stderrFd], - }); - return { ...launched, startTime: readProcessStartTime(launched.pid) ?? undefined }; - } finally { - fs.closeSync(stdoutFd); - fs.closeSync(stderrFd); - } + return launchDaemonProcess({ + paths: settings.paths, + serverMode: settings.serverMode, + ownedStateDir: settings.ownedStateDir, + args: launchSpec.useSrc + ? ['--experimental-strip-types', launchSpec.srcPath] + : [launchSpec.distPath], + }); } function describeDaemonEarlyExit(exit: ExecDetachedExit): string { @@ -771,3 +928,21 @@ function isLoopbackHostname(hostname: string): boolean { if (net.isIPv6(normalized)) return LOOPBACK_BLOCK_LIST.check(normalized, 'ipv6'); return false; } + +export function attachSessionAddressHints( + response: DaemonResponse, + req: Omit, + settings: DaemonClientSettings, +): DaemonResponse { + if (!response.ok) { + return settings.ownedStateDir && isHeldRepairDivergence(response) + ? attachRepairSessionAddressHint(response, settings.paths.baseDir) + : response; + } + return isActiveReplaySessionResponse(req, response) + ? attachActiveSessionAddressHint( + response, + settings.ownedStateDir ? settings.paths.baseDir : undefined, + ) + : response; +} diff --git a/src/daemon-client/daemon-client-metadata.ts b/src/daemon-client/daemon-client-metadata.ts index 3a448b099f..718ed7b1cc 100644 --- a/src/daemon-client/daemon-client-metadata.ts +++ b/src/daemon-client/daemon-client-metadata.ts @@ -1,12 +1,5 @@ import fs from 'node:fs'; -import { AppError } from '@agent-device/kernel/errors'; -import { shellQuote } from '@agent-device/kernel/device-shell'; -import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; -import { - isAgentDeviceDaemonProcess, - stopDaemonProcess, - type DaemonTerminationResult, -} from '../daemon-process.ts'; +import { isProcessPid } from '@agent-device/host-kit/process'; import type { DaemonCodeOrigin } from '@agent-device/host-kit/code-signature'; @@ -33,33 +26,11 @@ export type DaemonInfo = { remoteUpstreamInstanceId?: string; }; -type DaemonLockInfo = { - pid: number; - processStartTime?: string; - startedAt?: number; -}; - export type DaemonMetadataState = { hasInfo: boolean; hasLock: boolean; }; -type DaemonStartupCleanupReason = 'start_error' | 'startup_timeout'; - -export type DaemonStartupCleanupResult = { - reason: DaemonStartupCleanupReason; - removedInfo: boolean; - removedLock: boolean; - stoppedInfoProcess: boolean; - stoppedLockProcess: boolean; - retainedInfoProcess?: boolean; - retainedLockProcess?: boolean; - error?: string; -}; - -const DAEMON_TAKEOVER_TERM_TIMEOUT_MS = 3000; -const DAEMON_TAKEOVER_KILL_TIMEOUT_MS = 1000; - export function readDaemonInfo(infoPath: string): DaemonInfo | null { const data = readJsonFile(infoPath); if (!data || typeof data !== 'object') return null; @@ -72,7 +43,7 @@ export function readDaemonInfo(infoPath: string): DaemonInfo | null { token, ...ports, transport: readDaemonInfoTransport(parsed.transport), - pid: readPositiveInteger(parsed.pid) ?? 0, + pid: isProcessPid(parsed.pid) ? parsed.pid : 0, version: readOptionalString(parsed.version), codeOrigin: readDaemonInfoCodeOrigin(parsed.codeOrigin), codeSignature: readOptionalString(parsed.codeSignature), @@ -110,129 +81,6 @@ function readPositiveInteger(value: unknown): number | undefined { return Number.isInteger(value) && Number(value) > 0 ? Number(value) : undefined; } -function readDaemonLockInfo(lockPath: string): DaemonLockInfo | null { - const data = readJsonFile(lockPath); - if (!data || typeof data !== 'object') return null; - const parsed = data as Partial; - const hasPid = Number.isInteger(parsed.pid) && Number(parsed.pid) > 0; - if (!hasPid) { - return null; - } - return { - pid: Number(parsed.pid), - processStartTime: - typeof parsed.processStartTime === 'string' ? parsed.processStartTime : undefined, - startedAt: typeof parsed.startedAt === 'number' ? parsed.startedAt : undefined, - }; -} - -/** - * Whether a live daemon other than `pid` holds the startup lock: another client's daemon won the - * start, and the daemon at `pid` exited because it lost the lock. - */ -export function isDaemonLockHeldByAnotherDaemon(paths: DaemonPaths, pid: number): boolean { - const lockInfo = readDaemonLockInfo(paths.lockPath); - return ( - lockInfo !== null && - lockInfo.pid !== pid && - isAgentDeviceDaemonProcess(lockInfo.pid, lockInfo.processStartTime) - ); -} - -export function removeDaemonInfo(infoPath: string): void { - removeFileIfExists(infoPath); -} - -export function removeDaemonLock(lockPath: string): void { - removeFileIfExists(lockPath); -} - -export function cleanupStaleDaemonLockIfSafe(paths: DaemonPaths): void { - const state = getDaemonMetadataState(paths); - if (!state.hasLock || state.hasInfo) return; - const lockInfo = readDaemonLockInfo(paths.lockPath); - if (!lockInfo) { - removeDaemonLock(paths.lockPath); - return; - } - if (isAgentDeviceDaemonProcess(lockInfo.pid, lockInfo.processStartTime)) { - return; - } - removeDaemonLock(paths.lockPath); -} - -export async function cleanupFailedDaemonStartupMetadata( - paths: DaemonPaths, - reason: DaemonStartupCleanupReason, - options: { stopLiveProcesses?: boolean } = {}, -): Promise { - const stopLiveProcesses = options.stopLiveProcesses ?? true; - const result: DaemonStartupCleanupResult = { - reason, - removedInfo: false, - removedLock: false, - stoppedInfoProcess: false, - stoppedLockProcess: false, - }; - - try { - const infoExists = fs.existsSync(paths.infoPath); - const info = readDaemonInfo(paths.infoPath); - if (info) { - const liveInfoProcess = isAgentDeviceDaemonProcess(info.pid, info.processStartTime); - if (liveInfoProcess && !stopLiveProcesses) { - result.retainedInfoProcess = true; - } else { - if (liveInfoProcess) { - await stopDaemonProcessForTakeover(info); - result.stoppedInfoProcess = true; - } - removeDaemonInfo(paths.infoPath); - result.removedInfo = true; - } - } else if (infoExists) { - removeDaemonInfo(paths.infoPath); - result.removedInfo = true; - } - - const lockExists = fs.existsSync(paths.lockPath); - const lockInfo = readDaemonLockInfo(paths.lockPath); - if (lockInfo) { - const liveLockProcess = isAgentDeviceDaemonProcess(lockInfo.pid, lockInfo.processStartTime); - if (liveLockProcess && !stopLiveProcesses) { - result.retainedLockProcess = true; - } else { - if (liveLockProcess) { - const termination = await stopDaemonProcess( - { pid: lockInfo.pid, startTime: lockInfo.processStartTime ?? null }, - { - mode: 'graceful', - termTimeoutMs: DAEMON_TAKEOVER_TERM_TIMEOUT_MS, - killTimeoutMs: DAEMON_TAKEOVER_KILL_TIMEOUT_MS, - }, - ); - requireDaemonExit(termination); - result.stoppedLockProcess = true; - } - removeDaemonLock(paths.lockPath); - result.removedLock = true; - } - } else if (lockExists) { - removeDaemonLock(paths.lockPath); - result.removedLock = true; - } - } catch (error) { - result.error = error instanceof Error ? error.message : String(error); - } - - emitDiagnostic({ - level: result.error ? 'warn' : 'info', - phase: 'daemon_startup_metadata_cleanup', - data: result, - }); - return result; -} - export function getDaemonMetadataState(paths: DaemonPaths): DaemonMetadataState { return { hasInfo: fs.existsSync(paths.infoPath), @@ -240,44 +88,6 @@ export function getDaemonMetadataState(paths: DaemonPaths): DaemonMetadataState }; } -export async function recoverDaemonLockHolder(paths: DaemonPaths): Promise { - const state = getDaemonMetadataState(paths); - if (!state.hasLock || state.hasInfo) return false; - const lockInfo = readDaemonLockInfo(paths.lockPath); - if (!lockInfo) { - removeDaemonLock(paths.lockPath); - return true; - } - if (!isAgentDeviceDaemonProcess(lockInfo.pid, lockInfo.processStartTime)) { - removeDaemonLock(paths.lockPath); - return true; - } - return false; -} - -export async function stopDaemonProcessForTakeover( - info: DaemonInfo, -): Promise { - const termination = await stopDaemonProcess( - { pid: info.pid, startTime: info.processStartTime ?? null }, - { - mode: 'graceful', - termTimeoutMs: DAEMON_TAKEOVER_TERM_TIMEOUT_MS, - killTimeoutMs: DAEMON_TAKEOVER_KILL_TIMEOUT_MS, - }, - ); - requireDaemonExit(termination); - return termination; -} - -function requireDaemonExit(termination: DaemonTerminationResult): void { - if (termination.status !== 'retained') return; - throw new AppError('COMMAND_FAILED', 'Daemon exit could not be confirmed.', { - reason: 'daemon_exit_unconfirmed', - termination, - }); -} - export function isRemoteDaemon(info: DaemonInfo): boolean { return typeof info.baseUrl === 'string' && info.baseUrl.length > 0; } @@ -288,21 +98,10 @@ export function resolveDaemonStartupHint( process.env.AGENT_DEVICE_STATE_DIR, ), ): string { - const cleanupCommand = buildDaemonMetadataCleanupCommand(paths); - if (state.hasLock && !state.hasInfo) { - return `agent-device attempted to clean stale daemon metadata automatically, but ${paths.lockPath} still exists without ${paths.infoPath}. Retry with --debug; if this persists after confirming no agent-device daemon process is running, run: ${cleanupCommand}`; - } - if (state.hasLock && state.hasInfo) { - return `agent-device attempted to clean stale daemon metadata automatically, but ${paths.infoPath} and ${paths.lockPath} still remain. Retry with --debug; if this persists after confirming no agent-device daemon process is running, run: ${cleanupCommand}`; - } - if (state.hasInfo) { - return `agent-device did not observe reachable daemon metadata after retrying, and ${paths.infoPath} still remains. Stale metadata was cleaned automatically when safe; retry with --debug. If this persists after confirming no agent-device daemon process is running, run: ${cleanupCommand}`; - } - return `agent-device did not observe reachable daemon metadata after retrying. Stale metadata was cleaned automatically when safe; retry with --debug and check daemon diagnostics logs. If stale metadata returns after confirming no agent-device daemon process is running, run: ${cleanupCommand}`; -} - -function buildDaemonMetadataCleanupCommand(paths: Pick) { - return `rm -f ${shellQuote(paths.infoPath)} ${shellQuote(paths.lockPath)}`; + const artifacts = [state.hasInfo ? paths.infoPath : null, state.hasLock ? paths.lockPath : null] + .filter(Boolean) + .join(' and '); + return `Daemon startup did not establish a reachable owner. ${artifacts ? `State was retained at ${artifacts}. ` : ''}Retry with --debug and inspect daemon diagnostics. Before upgrading, stop all older clients and daemons with their original CLI and prevent them from returning to this state directory. Unverified lock state requires confirming every user stopped before manual recovery; deleting metadata alone is not a safe reset.`; } function readJsonFile(filePath: string): unknown | null { @@ -313,11 +112,3 @@ function readJsonFile(filePath: string): unknown | null { return null; } } - -function removeFileIfExists(filePath: string): void { - try { - if (fs.existsSync(filePath)) fs.unlinkSync(filePath); - } catch { - // Best-effort cleanup only. - } -} diff --git a/src/daemon-client/daemon-client-timeout.ts b/src/daemon-client/daemon-client-timeout.ts index d338f74d02..12c87c0d93 100644 --- a/src/daemon-client/daemon-client-timeout.ts +++ b/src/daemon-client/daemon-client-timeout.ts @@ -1,18 +1,13 @@ -import { AppError, normalizeError } from '@agent-device/kernel/errors'; +import { AppError } from '@agent-device/kernel/errors'; import { runCmdSync } from '@agent-device/host-kit/command'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; -import { isAgentDeviceDaemonProcess } from '../daemon-process.ts'; +import type { DaemonRetirementResult } from '../daemon-registration-owner.ts'; import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; import { resolveCommandTimeoutPolicy } from '@agent-device/command-registry/registry'; import type { DaemonPaths } from '../daemon-resolution.ts'; import type { PlatformSelector } from '@agent-device/kernel/device'; -import { - removeDaemonInfo, - removeDaemonLock, - stopDaemonProcessForTakeover, - type DaemonInfo, -} from './daemon-client-metadata.ts'; +import type { DaemonInfo } from './daemon-client-metadata.ts'; const IOS_RUNNER_XCODEBUILD_KILL_PATTERNS = [ 'xcodebuild .*AgentDeviceRunnerUITests/RunnerTests/testCommand', @@ -40,7 +35,7 @@ function isAffirmativelyApplePlatform(platform: PlatformSelector | undefined): b return platform !== undefined && AFFIRMATIVE_APPLE_PLATFORM_SELECTORS.has(platform); } -export function handleRequestTimeout( +export async function handleRequestTimeout( params: Readonly<{ info: DaemonInfo; statePaths: DaemonPaths; @@ -53,7 +48,7 @@ export function handleRequestTimeout( session?: string; action?: string; }>, -): AppError { +): Promise { const { info, statePaths, remote, timeoutMs, requestId, command, platform, session, action } = params; // Cleanup eligibility stays UNCONDITIONAL for every local (non-remote) @@ -69,9 +64,17 @@ export function handleRequestTimeout( // a wrong skip. const cleanup = remote ? { terminated: 0 } : cleanupTimedOutIosRunnerBuilds(); const resetDaemon = !remote && shouldResetDaemonAfterRequestTimeout(command); - const daemonReset = resetDaemon - ? resetDaemonAfterTimeout(info, statePaths) - : { forcedKill: false }; + let retirement: DaemonRetirementResult | undefined; + if (resetDaemon) { + const { stopAndRetireDaemon } = await import('../daemon-registration-owner.ts'); + retirement = await stopAndRetireDaemon({ + paths: statePaths, + observed: { pid: info.pid, startTime: info.processStartTime ?? null }, + mode: 'force', + }); + } + const preserved = + retirement?.status === 'retained' && retirement.termination?.status !== 'exited'; // The HINT, unlike cleanup, may only name Apple-runner involvement on // evidence this call site actually has: an explicitly declared Apple // platform selector, or the cleanup itself having terminated a matching @@ -89,9 +92,10 @@ export function handleRequestTimeout( command, timedOutRunnerPidsTerminated: cleanup.terminated, timedOutRunnerCleanupError: cleanup.error, - daemonPidReset: resetDaemon ? info.pid : undefined, - daemonPidForceKilled: resetDaemon ? daemonReset.forcedKill : undefined, - daemonPreservedAfterTimeout: !remote && !resetDaemon, + daemonPidReset: retirement?.status === 'retired' ? info.pid : undefined, + daemonPidForceKilled: resetDaemon ? daemonWasForceKilled(retirement) : undefined, + daemonRetirement: retirement, + daemonPreservedAfterTimeout: preserved || (!remote && !resetDaemon), daemonBaseUrl: info.baseUrl, }, }); @@ -99,17 +103,27 @@ export function handleRequestTimeout( timeoutMs, requestId, reason: 'daemon_transport_timeout', - hint: resolveRequestTimeoutHint({ - remote, - resetDaemon, - command, - appleCleanupEvidence, - session, - action, - }), + ...(retirement ? { retirement, stateDir: statePaths.baseDir } : {}), + hint: + retirement?.status === 'retained' + ? `The daemon could not be safely retired. State was retained at ${statePaths.baseDir}. ${retirement.error?.hint ?? 'Retry with --debug and inspect daemon diagnostics before retrying.'}` + : resolveRequestTimeoutHint({ + remote, + resetDaemon, + command, + appleCleanupEvidence, + session, + action, + }), }); } +function daemonWasForceKilled(retirement: DaemonRetirementResult | undefined): boolean { + if (!retirement || retirement.status === 'absent') return false; + const termination = retirement.termination; + return termination?.status === 'exited' && termination.mode === 'forced'; +} + // Whether a timed-out request tears down the local daemon is declared on the // command's descriptor (ADR 0008, `timeoutPolicy.onTimeout`): read-only // capture/polling commands preserve the daemon so sessions survive and evidence @@ -177,25 +191,3 @@ function cleanupTimedOutIosRunnerBuilds(): { terminated: number; error?: string }; } } - -function resetDaemonAfterTimeout(info: DaemonInfo, paths: DaemonPaths): { forcedKill: boolean } { - let forcedKill = false; - try { - if (isAgentDeviceDaemonProcess(info.pid, info.processStartTime)) { - process.kill(info.pid, 'SIGKILL'); - forcedKill = true; - } - } catch { - void stopDaemonProcessForTakeover(info).catch((error: unknown) => { - emitDiagnostic({ - level: 'warn', - phase: 'daemon_timeout_stop_failed', - data: { error: normalizeError(error) }, - }); - }); - } finally { - removeDaemonInfo(paths.infoPath); - removeDaemonLock(paths.lockPath); - } - return { forcedKill }; -} diff --git a/src/daemon-client/daemon-client-transport.ts b/src/daemon-client/daemon-client-transport.ts index d09d5af386..66d545b0a3 100644 --- a/src/daemon-client/daemon-client-transport.ts +++ b/src/daemon-client/daemon-client-transport.ts @@ -72,23 +72,34 @@ type RemoteDaemonHealthLink = Pick< export async function canConnect( info: DaemonInfo, preference: DaemonTransportPreference, + probeTimeoutMs?: number, ): Promise { + const deadline = Date.now() + (probeTimeoutMs ?? Number.POSITIVE_INFINITY); const transport = chooseTransport(info, preference); - if (await canConnectWithTransport(info, transport)) return true; - const fallback = chooseAutoFallbackTransport(info, preference, transport); - return fallback ? await canConnectWithTransport(info, fallback) : false; + const firstBudget = (deadline - Date.now()) / (fallback ? 2 : 1); + if (await canConnectWithTransport(info, transport, firstBudget)) return Date.now() < deadline; + return fallback + ? (await canConnectWithTransport(info, fallback, deadline - Date.now())) && + Date.now() < deadline + : false; } async function canConnectWithTransport( info: DaemonInfo, transport: ResolvedDaemonTransport, + timeoutMs: number, ): Promise { - return transport === 'http' ? await canConnectHttp(info) : await canConnectSocket(info.port); + return transport === 'http' + ? await canConnectHttp(info, timeoutMs) + : await canConnectSocket(info.port, timeoutMs); } -export function canConnectSocket(port: number | undefined): Promise { - if (!port) return Promise.resolve(false); +export function canConnectSocket( + port: number | undefined, + timeoutMs = LOCAL_DAEMON_HEALTHCHECK_TIMEOUT_MS, +): Promise { + if (!port || timeoutMs <= 0) return Promise.resolve(false); return new Promise((resolve) => { let settled = false; const socket = net.createConnection({ host: '127.0.0.1', port }, () => { @@ -100,7 +111,7 @@ export function canConnectSocket(port: number | undefined): Promise { socket.destroy(); resolve(reachable); }; - socket.setTimeout(LOCAL_DAEMON_HEALTHCHECK_TIMEOUT_MS); + socket.setTimeout(Math.min(LOCAL_DAEMON_HEALTHCHECK_TIMEOUT_MS, Math.ceil(timeoutMs))); socket.on('timeout', () => { finish(false); }); @@ -110,8 +121,8 @@ export function canConnectSocket(port: number | undefined): Promise { }); } -function canConnectHttp(info: DaemonInfo): Promise { - return readDaemonHttpHealth(info).then((health) => health.reachable); +function canConnectHttp(info: DaemonInfo, timeoutMs: number): Promise { + return readDaemonHttpHealth(info, timeoutMs).then((health) => health.reachable); } export async function readRemoteDaemonHealth( @@ -152,17 +163,27 @@ async function readDaemonHttpHealth( : null; if (!endpoint) return { reachable: false }; const url = new URL(endpoint); - const transport = await loadNodeHttpRequester(url.protocol); const timeoutMs = Math.min( info.baseUrl ? REMOTE_DAEMON_HEALTHCHECK_TIMEOUT_MS : LOCAL_DAEMON_HEALTHCHECK_TIMEOUT_MS, probeTimeoutMs ?? Number.POSITIVE_INFINITY, ); if (timeoutMs <= 0) return { reachable: false, timedOut: true }; + const deadline = performance.now() + timeoutMs; const signal = AbortSignal.timeout(Math.ceil(timeoutMs)); + const transport = await Promise.race([ + loadNodeHttpRequester(url.protocol), + new Promise((resolve) => { + signal.addEventListener('abort', () => resolve(null), { once: true }); + }), + ]); + if (!transport || healthProbeExpired(signal, deadline)) + return { reachable: false, timedOut: true }; return await new Promise((resolve) => { const headers = info.baseUrl ? buildDaemonHttpAuthHeaders(info.token) : {}; const unreachable = (): RemoteDaemonHealth => - signal.aborted ? { reachable: false, timedOut: true } : { reachable: false }; + healthProbeExpired(signal, deadline) + ? { reachable: false, timedOut: true } + : { reachable: false }; const req = transport.request( { protocol: url.protocol, @@ -182,11 +203,11 @@ async function readDaemonHttpHealth( }); res.on('end', () => { const statusCode = res.statusCode ?? 500; - resolve({ - reachable: statusCode < 500, - statusCode, - ...readHealthPayload(body), - }); + resolve( + healthProbeExpired(signal, deadline) + ? { reachable: false, timedOut: true } + : { reachable: statusCode < 500, statusCode, ...readHealthPayload(body) }, + ); }); res.on('error', () => resolve(unreachable())); res.on('aborted', () => resolve(unreachable())); @@ -203,6 +224,10 @@ async function readDaemonHttpHealth( }); } +function healthProbeExpired(signal: AbortSignal, deadline: number): boolean { + return signal.aborted || performance.now() >= deadline; +} + function readHealthPayload(body: string): Omit { try { const parsed = JSON.parse(body) as { upstream?: unknown }; @@ -237,6 +262,29 @@ export async function sendRequest( statePaths: DaemonPaths, timeoutMs: number | undefined, options: SendRequestOptions = {}, +): Promise { + try { + return await sendRequestWithFallback(info, req, preference, statePaths, timeoutMs, options); + } catch (error) { + if (!(error instanceof AppError) || error.details?.reason !== 'daemon_transport_timeout') + throw error; + const expiredBudget = error.details.timeoutMs; + if (typeof expiredBudget !== 'number') throw error; + throw await handleRequestTimeout({ + info, + statePaths, + ...timeoutRequestContext(req, isRemoteDaemon(info), expiredBudget), + }); + } +} + +async function sendRequestWithFallback( + info: DaemonInfo, + req: DaemonRequest, + preference: DaemonTransportPreference, + statePaths: DaemonPaths, + timeoutMs: number | undefined, + options: SendRequestOptions = {}, ): Promise { const transport = chooseTransport(info, preference); const deadline = typeof timeoutMs === 'number' ? performance.now() + timeoutMs : undefined; @@ -271,30 +319,14 @@ async function retryAfterRemoteInstanceMismatch( options: SendRequestOptions, ): Promise { invalidateRemoteDaemonHealth(info); - const probeTimeoutMs = remainingRemoteRequestTimeoutMs( - info, + const probeTimeoutMs = remainingRemoteRequestTimeoutMs(req, timeoutMs, deadline); + const health = await readRemoteDaemonHealth(info, probeTimeoutMs); + const remainingMs = remainingRemoteRequestTimeoutMs( req, - statePaths, timeoutMs, deadline, + health.timedOut ? probeTimeoutMs : undefined, ); - const health = await readRemoteDaemonHealth(info, probeTimeoutMs); - // The probe's timer starts from the event loop's cached clock, so it can expire while - // performance.now() is still short of the deadline: a probe the RPC deadline capped that ran out - // of time is the RPC timing out. - if ( - health.timedOut && - timeoutMs !== undefined && - probeTimeoutMs !== undefined && - probeTimeoutMs <= REMOTE_DAEMON_HEALTHCHECK_TIMEOUT_MS - ) { - throw handleRequestTimeout({ - info, - statePaths, - ...timeoutRequestContext(req, true, timeoutMs), - }); - } - const remainingMs = remainingRemoteRequestTimeoutMs(info, req, statePaths, timeoutMs, deadline); if (!health.reachable) { throw new AppError('COMMAND_FAILED', 'Remote daemon is unavailable', { daemonBaseUrl: info.baseUrl, @@ -312,20 +344,20 @@ async function retryAfterRemoteInstanceMismatch( } function remainingRemoteRequestTimeoutMs( - info: DaemonInfo, req: DaemonRequest, - statePaths: DaemonPaths, timeoutMs: number | undefined, deadline: number | undefined, + timedOutProbeMs?: number, ): number | undefined { if (deadline === undefined || timeoutMs === undefined) return undefined; const remainingMs = deadline - performance.now(); - if (remainingMs > 0) return remainingMs; - throw handleRequestTimeout({ - info, - statePaths, - ...timeoutRequestContext(req, true, timeoutMs), - }); + // A probe capped by the request can expire before the monotonic clock catches up to its timer. + if ( + remainingMs > 0 && + (timedOutProbeMs === undefined || timedOutProbeMs > REMOTE_DAEMON_HEALTHCHECK_TIMEOUT_MS) + ) + return remainingMs; + throw requestTimeoutError(timeoutMs, req.meta?.requestId); } function isRemoteInstanceMismatch(error: unknown): boolean { @@ -351,7 +383,7 @@ async function sendRequestWithTransport( ): Promise { return transport === 'http' ? await sendHttpRequest(info, req, statePaths, timeoutMs, options) - : await sendSocketRequest(info, req, statePaths, timeoutMs, options); + : await sendSocketRequest(info, req, timeoutMs, options); } function chooseTransport( @@ -446,7 +478,6 @@ function handleTransportError( async function sendSocketRequest( info: DaemonInfo, req: DaemonRequest, - statePaths: DaemonPaths, timeoutMs: number | undefined, options: SendRequestOptions, ): Promise { @@ -462,15 +493,10 @@ async function sendSocketRequest( const timeoutHandle = typeof timeoutMs === 'number' ? setTimeout(() => { + if (settled) return; settled = true; + reject(requestTimeoutError(timeoutMs, req.meta?.requestId)); socket.destroy(); - reject( - handleRequestTimeout({ - info, - statePaths, - ...timeoutRequestContext(req, false, timeoutMs), - }), - ); }, timeoutMs) : undefined; @@ -504,6 +530,14 @@ async function sendSocketRequest( }); } +function requestTimeoutError(timeoutMs: number, requestId: string | undefined): AppError { + return new AppError('COMMAND_FAILED', 'Daemon request timed out', { + reason: 'daemon_transport_timeout', + timeoutMs, + requestId, + }); +} + // The fields a timed-out request is described by, read once so a socket and an HTTP timeout cannot // describe the same request differently. type TimeoutRequestFields = Omit[0], 'info' | 'statePaths'>; @@ -636,14 +670,8 @@ async function sendHttpRequest( const timeoutHandle = typeof timeoutMs === 'number' ? setTimeout(() => { + reject(requestTimeoutError(timeoutMs, req.meta?.requestId)); request.destroy(); - reject( - handleRequestTimeout({ - info, - statePaths, - ...timeoutRequestContext(req, remote, timeoutMs), - }), - ); }, timeoutMs) : undefined; diff --git a/src/daemon-client/daemon-client.ts b/src/daemon-client/daemon-client.ts index 7d777254fb..4d5e97e561 100644 --- a/src/daemon-client/daemon-client.ts +++ b/src/daemon-client/daemon-client.ts @@ -17,17 +17,7 @@ import { prepareRemoteRequestArtifacts, type PreparedRemoteRequest, } from '../remote/daemon-artifacts.ts'; -import { - attachActiveSessionAddressHint, - attachRepairSessionAddressHint, - cleanupDaemonAfterRequest, - ensureDaemon, - isActiveReplaySessionResponse, - isHeldRepairDivergence, - resolveClientSettings, - type DaemonClientSettings, - type EnsuredDaemon, -} from './daemon-client-lifecycle.ts'; +import type { DaemonClientSettings, EnsuredDaemon } from './daemon-client-lifecycle.ts'; import { sendRequest } from './daemon-client-transport.ts'; import { isRemoteDaemon, type DaemonInfo } from './daemon-client-metadata.ts'; import { leaseScopeFromRequest } from '@agent-device/contracts/lease-scope'; @@ -42,6 +32,8 @@ export async function sendToDaemon( req: Omit, options: DaemonTransportOptions = {}, ): Promise { + const { resolveClientSettings, ensureDaemon, attachSessionAddressHints } = + await import('./daemon-client-lifecycle.ts'); const requestId = req.meta?.requestId ?? createRequestId(); const debug = Boolean(req.meta?.debug || req.flags?.verbose); // A few internal callers build DaemonRequest directly instead of using the @@ -107,11 +99,7 @@ export async function sendToDaemon( ), { requestId, command: req.command }, ); - return withActiveSessionAddressHint( - withRepairSessionAddressHintIfOwned(response, settings), - requestWithoutAuthFlag, - settings, - ); + return attachSessionAddressHints(response, requestWithoutAuthFlag, settings); }, ); } @@ -235,6 +223,7 @@ async function performDaemonRequestWithCleanup( requestFailed = true; requestError = error; } + const { cleanupDaemonAfterRequest } = await import('./daemon-client-lifecycle.ts'); const finalResponse = await cleanupDaemonAfterRequest(req, daemon, settings, response); if (requestFailed) throw requestError; if (!finalResponse) { @@ -246,48 +235,6 @@ async function performDaemonRequestWithCleanup( return finalResponse; } -/** - * ADR 0012 decision 6 (Fix 1): the owned ephemeral state dir this daemon was - * started at is otherwise unaddressable by a later invocation — hint it here, - * only when the daemon is actually being kept alive for it - * (`settings.ownedStateDir` means `daemon.startedByClient` is also true). - */ -function withRepairSessionAddressHintIfOwned( - response: DaemonResponse, - settings: DaemonClientSettings, -): DaemonResponse { - if (response.ok || !settings.ownedStateDir || !isHeldRepairDivergence(response)) { - return response; - } - return attachRepairSessionAddressHint(response, settings.paths.baseDir); -} - -/** - * ADR 0016 counterpart to `withRepairSessionAddressHintIfOwned` — but unlike - * that one, NOT gated on `settings.ownedStateDir`. An owned ephemeral state - * dir is unaddressable by a later invocation either way, so it's included - * when owned; an explicit `--state-dir`/`AGENT_DEVICE_STATE_DIR` caller - * already knows their own dir, so it's omitted then. But the session's own - * name is cwd-qualified and, per #1394, `session list` cannot rediscover it - * either — so `--session` is still worth hinting even at an explicit state - * dir, which is why this runs for every active-session response regardless - * of `ownedStateDir` (`attachActiveSessionAddressHint` itself decides what, - * if anything, is worth attaching). - */ -function withActiveSessionAddressHint( - response: DaemonResponse, - req: Omit, - settings: DaemonClientSettings, -): DaemonResponse { - if (!response.ok || !isActiveReplaySessionResponse(req, response)) { - return response; - } - return attachActiveSessionAddressHint( - response, - settings.ownedStateDir ? settings.paths.baseDir : undefined, - ); -} - function writeInstallInProgressNotice(command: string | undefined): void { if (!isInstallLikeCommand(command) || process.stderr.isTTY !== true || process.env.CI) return; process.stderr.write( diff --git a/src/daemon-process.ts b/src/daemon-process.ts index fee88d3622..28869a01b5 100644 --- a/src/daemon-process.ts +++ b/src/daemon-process.ts @@ -1,5 +1,6 @@ import { isProcessAlive, + isProcessPid, readHostProcessIdentityObservations, readProcessCommand, readProcessStartTime, @@ -72,6 +73,7 @@ export async function waitForDaemonExit( identity: DaemonProcessIdentity, options: { timeoutMs: number; pollMs?: number }, ): Promise { + if (!isProcessPid(identity.pid)) return { exited: false, elapsedMs: 0 }; const startedAt = Date.now(); const deadline = startedAt + options.timeoutMs; const pollMs = options.pollMs ?? DAEMON_EXIT_POLL_MS; @@ -114,6 +116,7 @@ export async function stopDaemonProcess( killTimeoutMs: number; }, ): Promise { + if (!isProcessPid(observed.pid)) return { status: 'retained', reason: 'identity-unverified' }; if (!observed.startTime?.trim()) { if (!isProcessAlive(observed.pid)) return { status: 'not-running' }; return { status: 'retained', reason: 'missing-start-time' }; diff --git a/src/daemon-registration-owner.ts b/src/daemon-registration-owner.ts index 0092f087af..d2afde67bb 100644 --- a/src/daemon-registration-owner.ts +++ b/src/daemon-registration-owner.ts @@ -1,11 +1,18 @@ import fs from 'node:fs'; -import { normalizeError, type NormalizedError } from '@agent-device/kernel/errors'; +import os from 'node:os'; +import path from 'node:path'; +import { runCmdDetachedMonitored, type ExecDetachedExit } from '@agent-device/host-kit/command'; +import { AppError, normalizeError, type NormalizedError } from '@agent-device/kernel/errors'; import { stopDaemonProcess, waitForDaemonExit, type DaemonTerminationResult, } from './daemon-process.ts'; -import { readCurrentOwnerIdentity, type OwnerIdentity } from '@agent-device/host-kit/process'; +import { + readCurrentOwnerIdentity, + readProcessStartTime, + type OwnerIdentity, +} from '@agent-device/host-kit/process'; import { publishFileSync, tryAcquireProcessLock, @@ -15,7 +22,12 @@ import { } from '@agent-device/host-kit/file'; import { emitDiagnostic, withDiagnosticsScope } from '@agent-device/host-kit/diagnostics'; import type { DaemonCodeOrigin } from '@agent-device/host-kit/code-signature'; -import type { DaemonPaths } from './daemon-resolution.ts'; +import { + resolveDaemonPaths, + type DaemonPaths, + type DaemonServerMode, +} from './daemon-resolution.ts'; +import { findUnrecoveredRepairCommitFailure } from './session-repair-tombstone.ts'; import { readRegisteredDaemonOwnership, type RegisteredDaemonOwnership, @@ -161,6 +173,95 @@ function truncateDaemonLog(logPath: string): void { } } +declare const privateReplayState: unique symbol; +export type OwnedReplayStateDir = Readonly<{ + paths: Readonly; + [privateReplayState]: true; +}>; +export type DaemonStartupLaunch = Readonly<{ + pid: number; + startTime?: string; + exited: Promise; +}>; +type OwnedStartup = { launch: DaemonStartupLaunch; joined: boolean }; +type PrivateReplayState = { + paths: Readonly; + startups: OwnedStartup[]; + sealed: boolean; + retirement?: Promise; +}; +const privateReplayStates = new WeakMap(); + +/** Creates deletion authority only for a fresh private replay directory. */ +export function createOwnedReplayStateDir(): OwnedReplayStateDir { + const paths = Object.freeze( + resolveDaemonPaths(fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-daemon-'))), + ); + const owned = Object.freeze({ paths }) as OwnedReplayStateDir; + privateReplayStates.set(owned, { paths, startups: [], sealed: false }); + return owned; +} + +/** Launches and monitors the actual child before recording its private-directory authority. */ +export function launchDaemonProcess( + input: Readonly<{ + paths: DaemonPaths; + args: string[]; + serverMode: DaemonServerMode; + ownedStateDir?: OwnedReplayStateDir; + }>, +): DaemonStartupLaunch { + const owned = input.ownedStateDir && requirePrivateReplayState(input.ownedStateDir, input.paths); + if (owned?.sealed) + throw new AppError('COMMAND_FAILED', 'Replay daemon startup admission is closed.', { + reason: 'daemon_startup_admission_closed', + }); + fs.mkdirSync(input.paths.baseDir, { recursive: true }); + const logFd = fs.openSync(input.paths.logPath, 'a'); + try { + const monitored = runCmdDetachedMonitored(process.execPath, input.args, { + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: input.paths.baseDir, + AGENT_DEVICE_DAEMON_SERVER_MODE: input.serverMode, + }, + stdio: ['ignore', logFd, logFd], + }); + const startup: OwnedStartup = { launch: Object.freeze({ ...monitored }), joined: false }; + if (owned) { + owned.startups.push(startup); + void monitored.exited.then(() => { + startup.joined = true; + }); + } + startup.launch = Object.freeze({ + ...startup.launch, + startTime: readProcessStartTime(monitored.pid) ?? undefined, + }); + return startup.launch; + } finally { + fs.closeSync(logFd); + } +} + +function requirePrivateReplayState( + capability: OwnedReplayStateDir, + paths: DaemonPaths, +): PrivateReplayState { + const owned = privateReplayStates.get(capability); + if ( + !owned || + Object.entries(owned.paths).some(([key, value]) => paths[key as keyof DaemonPaths] !== value) + ) { + throw new AppError( + 'COMMAND_FAILED', + 'Private replay directory ownership could not be verified.', + { reason: 'daemon_private_state_unowned' }, + ); + } + return owned; +} + export type DaemonRetirementInput = Readonly<{ paths: DaemonPaths; observed: OwnerIdentity | null; @@ -169,16 +270,25 @@ export type DaemonRetirementInput = Readonly<{ lockTimeoutMs?: number; }>; +type RepairCommitFailure = NonNullable>; type ConfirmedDaemonTermination = Extract; export type DaemonRetirementResult = - | Readonly<{ status: 'retired'; termination: ConfirmedDaemonTermination; removedInfo: boolean }> + | Readonly<{ + status: 'retired'; + termination: ConfirmedDaemonTermination; + removedInfo: boolean; + removedStateDir?: boolean; + repairCommitFailure?: RepairCommitFailure; + }> | Readonly<{ status: 'absent'; removedInfo: false }> | Readonly<{ status: 'retained'; termination?: DaemonTerminationResult; removedInfo: boolean; + removedStateDir?: boolean; reason: | 'ownership-unproven' + | 'startup-unconfirmed' | 'exit-unconfirmed' | 'stop-failed' | 'lock-busy' @@ -186,19 +296,59 @@ export type DaemonRetirementResult = | 'metadata-unreadable' | 'retirement-unconfirmed'; error?: NormalizedError; + repairCommitFailure?: RepairCommitFailure; }>; /** Stops only the captured daemon lifetime, then retires its registration under the startup lock. */ export async function stopAndRetireDaemon( - input: DaemonRetirementInput & Readonly<{ mode: 'graceful' | 'force' }>, + input: DaemonRetirementInput & + Readonly<{ + mode: 'graceful' | 'force'; + ownedStateDir?: OwnedReplayStateDir; + startupJoinTimeoutMs?: number; + }>, ): Promise { - return await retireObservedDaemon(input, (identity) => - stopDaemonProcess(identity, { - mode: input.mode, - termTimeoutMs: input.termTimeoutMs ?? 3_000, - killTimeoutMs: input.killTimeoutMs ?? 1_000, - }), + let owned: PrivateReplayState | undefined; + try { + if (input.ownedStateDir) { + owned = requirePrivateReplayState(input.ownedStateDir, input.paths); + owned.sealed = true; + const launch = owned.startups.find( + ({ launch }) => + launch.pid === input.observed?.pid && launch.startTime === input.observed?.startTime, + )?.launch; + if (!launch?.startTime) + throw new AppError( + 'COMMAND_FAILED', + 'The observed daemon is not an owned startup lifetime.', + { reason: 'daemon_private_startup_unowned' }, + ); + if (owned.retirement) return await owned.retirement; + } + } catch (error) { + return { + status: 'retained', + reason: 'ownership-unproven', + removedInfo: false, + error: normalizeError(error), + }; + } + const retirement = retireObservedDaemon( + input, + (identity) => + stopDaemonProcess(identity, { + mode: input.mode, + termTimeoutMs: input.termTimeoutMs ?? 3_000, + killTimeoutMs: input.killTimeoutMs ?? 1_000, + }), + owned, + input.startupJoinTimeoutMs ?? 1_000, ); + if (owned) owned.retirement = retirement; + const result = await retirement; + if (owned && (result.status === 'absent' || !result.removedStateDir)) + owned.retirement = undefined; + return result; } /** Recovers a confirmed abandoned registration without signaling a live process. */ @@ -217,6 +367,8 @@ export async function recoverAbandonedDaemonRegistration( async function retireObservedDaemon( input: DaemonRetirementInput, terminate: (identity: OwnerIdentity) => Promise, + owned?: PrivateReplayState, + startupJoinTimeoutMs = 1_000, ): Promise { const paths = { ...input.paths }; const observed = input.observed && { ...input.observed }; @@ -241,12 +393,16 @@ async function retireObservedDaemon( }; } } - return await retireDaemonRegistration({ ...input, paths }, termination); + if (owned && !(await joinOwnedStartups(owned, startupJoinTimeoutMs))) { + return { status: 'retained', reason: 'startup-unconfirmed', termination, removedInfo: false }; + } + return await retireDaemonRegistration({ ...input, paths }, termination, owned); } async function retireDaemonRegistration( input: DaemonRetirementInput, termination: ConfirmedDaemonTermination | undefined, + owned?: PrivateReplayState, ): Promise { const paths = input.paths; let acquisition: ProcessLockAcquisition; @@ -268,7 +424,11 @@ async function retireDaemonRegistration( error: failure, }; } - let result: DaemonRetirementResult; + let result: DaemonRetirementResult = { + status: 'retained', + reason: 'retirement-unconfirmed', + removedInfo: false, + }; try { const removal = removeRegistrationUnderLock( paths.infoPath, @@ -276,12 +436,13 @@ async function retireDaemonRegistration( acquisition, ); result = retirementAfterRemoval(removal, termination); + result = retirePrivateStateIfEligible(owned, acquisition, result); } catch (error) { result = { status: 'retained', reason: 'retirement-unconfirmed', termination, - removedInfo: false, + removedInfo: result.removedInfo, error: normalizeError(error), }; } @@ -339,3 +500,42 @@ async function recordRegistrationWarning( emitDiagnostic({ level: 'warn', phase, data: { error: normalizeError(error) } }); }); } + +async function joinOwnedStartups(owned: PrivateReplayState, timeoutMs: number): Promise { + if (owned.startups.every((startup) => startup.joined)) return true; + let timer: ReturnType | undefined; + try { + return await Promise.race([ + Promise.all(owned.startups.map(({ launch }) => launch.exited)).then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), timeoutMs); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +function retirePrivateStateIfEligible( + owned: PrivateReplayState | undefined, + acquisition: ProcessLockAcquisition, + result: DaemonRetirementResult, +): DaemonRetirementResult { + if (!owned || result.status !== 'retired') return result; + try { + acquisition.assertHeld(); + const failure = findUnrecoveredRepairCommitFailure(owned.paths.sessionsDir); + if (failure) return { ...result, removedStateDir: false, repairCommitFailure: failure }; + acquisition.assertHeld(); + fs.rmSync(owned.paths.baseDir, { recursive: true, force: true }); + return { ...result, removedStateDir: true }; + } catch (error) { + return { + ...result, + status: 'retained', + reason: 'retirement-unconfirmed', + removedStateDir: false, + error: normalizeError(error), + }; + } +} diff --git a/src/daemon-registration.ts b/src/daemon-registration.ts index ad598afc3c..22f892971b 100644 --- a/src/daemon-registration.ts +++ b/src/daemon-registration.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import { ownerIdentityDiffers, + isProcessPid, ownerIdentityMatches, type OwnerIdentity, } from '@agent-device/host-kit/process'; @@ -53,7 +54,7 @@ function parseRegistration(parsed: { processStartTime?: unknown; }): ParsedRegistration { const pid = parsed.pid; - if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) { + if (!isProcessPid(pid)) { return { pid: null, startTime: null }; } return { pid, startTime: readableStartTime(parsed.processStartTime) }; 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__/app-log-session-resource.test.ts b/src/daemon/__tests__/app-log-session-resource.test.ts index 84a3add4f2..de465b68e9 100644 --- a/src/daemon/__tests__/app-log-session-resource.test.ts +++ b/src/daemon/__tests__/app-log-session-resource.test.ts @@ -19,6 +19,28 @@ import { adoptStartedSessionAppLog, finishSessionAppLog } from '../app-log-sessi import { createNextAppLogFence } from '../app-log-start-preflight.ts'; import { appLogResourceStore } from '../app-log-resource-store.ts'; import type { SessionState } from '../session-state.ts'; +import { stopSessionAppLog } from '../session-teardown.ts'; + +test('teardown captures an adopted app log before its lazy import can cross retirement', async () => { + const context = makeContext(); + const runtime = makeStartResult(context); + await adoptStartedSessionAppLog({ + ...context, + ...runtime.result, + throwIfCanceled: () => {}, + }); + expect(context.ref.session.appLog).toBeUndefined(); + const stopping = stopSessionAppLog(context); + context.sessionStore.retire(context.ref); + const successor = context.sessionStore.publish(context.sessionName, { + ...context.session, + appName: 'successor', + }); + await stopping; + expect(runtime.forceCleanup).toHaveBeenCalledOnce(); + expect(context.sessionStore.requireCurrent(successor)).toBe(successor.session); + expect(context.sessionStore.requireCurrent(successor).appLog).toBeUndefined(); +}); test('start persists open recovery truth before adopting the live handle', async () => { const context = makeContext(); @@ -88,7 +110,7 @@ test('SessionStore failure after transfer disposes the transferred handle and pr const context = makeContext(); const runtime = makeStartResult(context); const primary = new Error('store adoption failed'); - vi.spyOn(context.sessionStore, 'set').mockImplementationOnce(() => { + vi.spyOn(context.sessionStore, 'update').mockImplementationOnce(() => { throw primary; }); await expect( @@ -296,7 +318,6 @@ test('app-log disposes on a failed finish because its retry is that same finish finishSessionAppLog({ intent: 'capture', ...context, - session: context.sessionStore.get(context.sessionName) ?? context.session, }), ).rejects.toBe(finishError); @@ -315,6 +336,140 @@ test('app-log disposes on a failed finish because its retry is that same finish ).not.toThrow(); }); +test('shutdown admission rejects a late start while its existing session still occupies the address', async () => { + const context = makeContext(); + const runtime = makeStartResult(context); + context.sessionStore.closeAdmission(); + await expect( + adoptStartedSessionAppLog({ ...context, ...runtime.result, throwIfCanceled: () => {} }), + ).rejects.toMatchObject({ details: { reason: 'daemon_shutting_down' } }); + expect(runtime.forceCleanup).toHaveBeenCalledOnce(); + expect(context.sessionStore.requireCurrent(context.ref).appLog).toBeUndefined(); + expect(appLogResourceStore.read(context.resourcePath)).toMatchObject({ + status: 'decoded', + envelope: { lifecycle: 'completed' }, + }); +}); + +test('failed adoption cannot terminalize successor evidence after its cleanup yields', async () => { + const context = makeContext(); + const runtime = makeStartResult(context); + let release!: () => void; + let entered!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + const cleaning = new Promise((resolve) => { + entered = resolve; + }); + runtime.forceCleanup.mockImplementationOnce(async () => { + entered(); + await held; + return { status: 'cleaned' }; + }); + const canceled = new AppError('CANCELED', 'canceled'); + const adoption = adoptStartedSessionAppLog({ + ...context, + ...runtime.result, + throwIfCanceled: () => { + throw canceled; + }, + }); + const rejected = expect(adoption).rejects.toBe(canceled); + await cleaning; + context.sessionStore.retire(context.ref); + const successor = context.sessionStore.publish(context.sessionName, { ...context.session }); + appLogResourceStore.write(context.resourcePath, runtime.result.envelope); + release(); + await rejected; + expect(context.sessionStore.requireCurrent(successor).appLog).toBeUndefined(); + expect(appLogResourceStore.read(context.resourcePath)).toMatchObject({ + status: 'decoded', + envelope: { lifecycle: 'open' }, + }); +}); + +test('late adoption disposes its pending handle without overwriting a successor manifest', async () => { + const context = makeContext(); + const runtime = makeStartResult(context); + context.sessionStore.retire(context.ref); + const successor = context.sessionStore.publish(context.sessionName, { ...context.session }); + const envelope = { ...runtime.result.envelope, fence: { token: 'successor', generation: 2 } }; + appLogResourceStore.write(context.resourcePath, envelope); + await expect( + adoptStartedSessionAppLog({ ...context, ...runtime.result, throwIfCanceled: () => {} }), + ).rejects.toMatchObject({ details: { reason: 'session_lifetime_ended' } }); + expect(runtime.forceCleanup).toHaveBeenCalledOnce(); + expect(context.sessionStore.requireCurrent(successor).appLog).toBeUndefined(); + expect(appLogResourceStore.read(context.resourcePath)).toMatchObject({ + status: 'decoded', + envelope, + }); +}); + +test.each(['rebuild', 'retire', 'replace-resource'] as const)( + 'finishing app log after %s preserves the current record and its other fields', + async (change) => { + const context = makeContext(); + const { + result: { envelope }, + } = makeStartResult(context); + let enter!: () => void; + let resume!: () => void; + const entered = new Promise((resolve) => { + enter = resolve; + }); + const release = new Promise((resolve) => { + resume = resolve; + }); + const finish = vi.fn(async () => { + enter(); + await release; + return { + status: 'completed' as const, + result: { backend: 'android' as const, outputPath: '/tmp/app.log', completedAt: 2 }, + }; + }); + const handle = createTestAppLogLiveHandle({ + inspect: () => ({ backend: 'android', state: 'active', startedAt: 1 }), + finish, + forceCleanup: async () => ({ status: 'cleaned' }), + }); + await adoptStartedSessionAppLog({ + ...context, + envelope, + pendingHandle: new PendingTransferGuard(handle), + throwIfCanceled: () => {}, + }); + const finishing = finishSessionAppLog({ ...context, intent: 'capture' }); + await entered; + let currentRef = context.ref; + const active = context.sessionStore.requireCurrent(currentRef).appLog!; + const replacementHandle = makeStartResult(context).handle; + if (change === 'retire') { + context.sessionStore.retire(currentRef); + currentRef = context.sessionStore.publish(context.sessionName, { + ...context.session, + appLog: active, + appName: 'successor', + }); + } else { + context.sessionStore.update(currentRef, { + appName: 'updated', + appLog: + change === 'replace-resource' ? { ...active, handle: replacementHandle } : { ...active }, + }); + } + resume(); + await finishing; + expect(finish).toHaveBeenCalledOnce(); + const current = context.sessionStore.requireCurrent(currentRef); + expect(current.appName).toBe(change === 'retire' ? 'successor' : 'updated'); + if (change === 'rebuild') expect(current.appLog).toBeUndefined(); + else expect(current.appLog?.handle).toBe(change === 'retire' ? handle : replacementHandle); + }, +); + function makeContext( device: DeviceInfo = { platform: 'android', @@ -331,10 +486,11 @@ function makeContext( createdAt: Date.now(), actions: [], }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const resourcePath = appLogResourceStore.resolvePath(sessionStore.resolveSessionDir(sessionName)); return { admissionLedger: createAppLogAdmissionLedger(), + ref: sessionStore.lookup(sessionName)!, session, sessionName, sessionStore, diff --git a/src/daemon/__tests__/back-runtime.test.ts b/src/daemon/__tests__/back-runtime.test.ts index e2946824c3..55dd261633 100644 --- a/src/daemon/__tests__/back-runtime.test.ts +++ b/src/daemon/__tests__/back-runtime.test.ts @@ -138,7 +138,7 @@ test('request router joins back admission to execution, recording, and ref inval const sessionStore = makeSessionStore('agent-device-back-generic-'); const session = makeSession('back-runtime', { device: appleDevice }); activateCompleteRefFrame(session); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const handler = createRequestHandler({ logPath: '/tmp/daemon.log', token: 't', diff --git a/src/daemon/__tests__/capture-disclosure.test.ts b/src/daemon/__tests__/capture-disclosure.test.ts index e848ded858..59459f360f 100644 --- a/src/daemon/__tests__/capture-disclosure.test.ts +++ b/src/daemon/__tests__/capture-disclosure.test.ts @@ -75,7 +75,7 @@ beforeEach(() => { test('mutating find on a system-surface capture discloses the occlusion on the found outcome', async () => { const sessionStore = makeSessionStore(); const session = makeAndroidSession('default'); - sessionStore.set('default', session); + sessionStore.publish('default', session); const response = await handleFindCommands({ req: { @@ -102,7 +102,7 @@ test('mutating find on a system-surface capture discloses the occlusion on the f test('read-only find exists on a system-surface capture discloses the occlusion', async () => { const sessionStore = makeSessionStore(); const session = makeAndroidSession('default'); - sessionStore.set('default', session); + sessionStore.publish('default', session); const response = await dispatchFindReadOnlyViaRuntime({ req: { @@ -128,7 +128,7 @@ test('read-only find exists on a system-surface capture discloses the occlusion' test('wait timeout for app text hidden behind a system surface discloses the occlusion', async () => { const sessionStore = makeSessionStore(); const session = makeAndroidSession('default'); - sessionStore.set('default', session); + sessionStore.publish('default', session); const response = await dispatchWaitViaRuntime({ req: { @@ -304,7 +304,7 @@ function serveSheetCapture(): void { test('mutating find on an in-place system surface discloses it on the found outcome', async () => { serveSheetCapture(); const sessionStore = makeSessionStore(); - sessionStore.set('default', makeIosSession('default', { appBundleId: 'com.example.app' })); + sessionStore.publish('default', makeIosSession('default', { appBundleId: 'com.example.app' })); const response = await handleFindCommands({ req: { @@ -331,7 +331,7 @@ test('mutating find on an in-place system surface discloses it on the found outc test('mutating find that misses on an in-place system surface still discloses it', async () => { serveSheetCapture(); const sessionStore = makeSessionStore(); - sessionStore.set('default', makeIosSession('default', { appBundleId: 'com.example.app' })); + sessionStore.publish('default', makeIosSession('default', { appBundleId: 'com.example.app' })); const response = await handleFindCommands({ req: { @@ -378,7 +378,7 @@ test('the shared disclosure helper reports an iOS system surface on both outcome */ test('wait timeout whose polls required a foreground repair discloses the repair', async () => { const sessionStore = makeSessionStore(); - sessionStore.set('default', makeIosSession('default', { appBundleId: 'com.example.app' })); + sessionStore.publish('default', makeIosSession('default', { appBundleId: 'com.example.app' })); legacyDispatchCapture.mockResolvedValue({ backend: 'xctest', truncated: false, diff --git a/src/daemon/__tests__/daemon-policy.test.ts b/src/daemon/__tests__/daemon-policy.test.ts index 679f45fca9..489eaf5745 100644 --- a/src/daemon/__tests__/daemon-policy.test.ts +++ b/src/daemon/__tests__/daemon-policy.test.ts @@ -40,7 +40,7 @@ function makeHandler( options: { inventory?: readonly DeviceInfo[]; providerInventory?: readonly DeviceInfo[] } = {}, ) { const sessionStore = makeSessionStore('agent-device-daemon-policy-'); - sessionStore.set('default', makeIosSession('default', { appBundleId: 'com.example.app' })); + sessionStore.publish('default', makeIosSession('default', { appBundleId: 'com.example.app' })); const bind = vi.fn(lifecycleDeviceRuntimeGateway.bind); const inspectFacts = vi.fn(lifecycleDeviceRuntimeGateway.inspectFacts); const handler = createRequestHandler({ diff --git a/src/daemon/__tests__/daemon-runtime-app-log.test.ts b/src/daemon/__tests__/daemon-runtime-app-log.test.ts index baad8d7a7a..08c46cb91b 100644 --- a/src/daemon/__tests__/daemon-runtime-app-log.test.ts +++ b/src/daemon/__tests__/daemon-runtime-app-log.test.ts @@ -123,7 +123,7 @@ test('daemon shutdown settles fenced app-log cleanup before finalization can rel forceCleanup, }); session.appLog = { handle, envelope }; - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const resourcePath = appLogResourceStore.resolvePath( sessionStore.resolveSessionDir(session.name), ); @@ -132,7 +132,7 @@ test('daemon shutdown settles fenced app-log cleanup before finalization can rel const beforeDelete = vi.fn(async () => {}); const teardown = teardownDaemonSessionForShutdown({ - session, + ref: sessionStore.lookup(session.name)!, sessionStore, stderr: { write: () => {} }, beforeDelete, diff --git a/src/daemon/__tests__/daemon-stop.test.ts b/src/daemon/__tests__/daemon-stop.test.ts index 8a294910b6..13ac6851d4 100644 --- a/src/daemon/__tests__/daemon-stop.test.ts +++ b/src/daemon/__tests__/daemon-stop.test.ts @@ -2,15 +2,9 @@ import fs from 'node:fs'; import { afterEach, expect, test, vi } from 'vitest'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; -const mocks = vi.hoisted(() => ({ - stopDaemonProcess: vi.fn(), - sleep: vi.fn(async () => undefined), -})); - -vi.mock('../../daemon-process.ts', () => ({ stopDaemonProcess: mocks.stopDaemonProcess })); -vi.mock('@agent-device/host-kit/retry', async (importOriginal) => ({ - ...(await importOriginal()), - sleep: mocks.sleep, +const mocks = vi.hoisted(() => ({ stopAndRetireDaemon: vi.fn() })); +vi.mock('../../daemon-registration-owner.ts', () => ({ + stopAndRetireDaemon: mocks.stopAndRetireDaemon, })); import { resolveDaemonPaths } from '../../daemon-resolution.ts'; @@ -45,40 +39,34 @@ test('reports not-running when daemon metadata is absent', async () => { test('retained identity verification is reported as failure without known cleanup', async () => { const paths = createDaemonPaths(); - mocks.stopDaemonProcess.mockResolvedValue({ status: 'retained', reason: 'identity-unverified' }); + mocks.stopAndRetireDaemon.mockResolvedValue(retainedExit('identity-unverified')); await expect(stopDaemon({ paths })).rejects.toMatchObject({ code: 'COMMAND_FAILED', details: { reason: 'daemon_exit_unconfirmed', terminationReason: 'identity-unverified' }, }); - expect(mocks.stopDaemonProcess).toHaveBeenCalledWith( - { pid: 123, startTime: 'start-time' }, - { - mode: 'graceful', - termTimeoutMs: 10_000, - killTimeoutMs: 2_000, - }, - ); + expect(mocks.stopAndRetireDaemon).toHaveBeenCalledWith({ + paths, + observed: { pid: 123, startTime: 'start-time' }, + mode: 'graceful', + termTimeoutMs: 10_000, + killTimeoutMs: 2_000, + }); }); test('missing start-time identity is passed to the owning termination operation', async () => { const paths = createDaemonPaths(); fs.writeFileSync(paths.infoPath, JSON.stringify({ pid: 123, processStartTime: ' ' })); - mocks.stopDaemonProcess.mockResolvedValue({ status: 'retained', reason: 'missing-start-time' }); + mocks.stopAndRetireDaemon.mockResolvedValue(retainedExit('missing-start-time')); await expect(stopDaemon({ paths })).rejects.toMatchObject({ details: { terminationReason: 'missing-start-time' }, }); - expect(mocks.stopDaemonProcess).toHaveBeenCalledWith( - { pid: 123, startTime: null }, - expect.anything(), + expect(mocks.stopAndRetireDaemon).toHaveBeenCalledWith( + expect.objectContaining({ paths, observed: { pid: 123, startTime: null } }), ); }); test('a previously exited verified lifetime is reported as not-running', async () => { - mocks.stopDaemonProcess.mockResolvedValue({ - status: 'exited', - mode: 'already-exited', - identity: { pid: 123, startTime: 'start-time' }, - }); + mocks.stopAndRetireDaemon.mockResolvedValue(retired('already-exited')); expect(await stopDaemon({ paths: createDaemonPaths() })).toMatchObject({ stopped: false, mode: 'not-running', @@ -86,8 +74,15 @@ test('a previously exited verified lifetime is reported as not-running', async ( }); test('an already released pid without start time remains not-running without cleanup proof', async () => { - mocks.stopDaemonProcess.mockResolvedValue({ status: 'not-running' }); - expect(await stopDaemon({ paths: createDaemonPaths() })).toMatchObject({ + const paths = createDaemonPaths(); + fs.writeFileSync(paths.infoPath, JSON.stringify({ pid: 123 })); + mocks.stopAndRetireDaemon.mockResolvedValue({ + status: 'retained', + reason: 'exit-unconfirmed', + removedInfo: false, + termination: { status: 'not-running' }, + }); + expect(await stopDaemon({ paths })).toMatchObject({ stopped: false, mode: 'not-running', }); @@ -95,32 +90,24 @@ test('an already released pid without start time remains not-running without cle test('confirmed TERM exit preserves graceful report behavior and configured budgets', async () => { const paths = createDaemonPaths(); - mocks.stopDaemonProcess.mockImplementation(async () => { - fs.rmSync(paths.infoPath, { force: true }); - return { status: 'exited', mode: 'graceful', identity: { pid: 123, startTime: 'start-time' } }; - }); + mocks.stopAndRetireDaemon.mockResolvedValue(retired('graceful')); expect(await stopDaemon({ paths, graceTimeoutMs: 11, killTimeoutMs: 7 })).toMatchObject({ stopped: true, mode: 'graceful', cleanupConfidence: 'known', providerReleases: { pending: [] }, }); - expect(mocks.stopDaemonProcess).toHaveBeenCalledWith( - { pid: 123, startTime: 'start-time' }, - { - mode: 'graceful', - termTimeoutMs: 11, - killTimeoutMs: 7, - }, - ); + expect(mocks.stopAndRetireDaemon).toHaveBeenCalledWith({ + paths, + observed: { pid: 123, startTime: 'start-time' }, + mode: 'graceful', + termTimeoutMs: 11, + killTimeoutMs: 7, + }); }); test('confirmed KILL exit preserves unknown provider cleanup', async () => { - mocks.stopDaemonProcess.mockResolvedValue({ - status: 'exited', - mode: 'forced', - identity: { pid: 123, startTime: 'start-time' }, - }); + mocks.stopAndRetireDaemon.mockResolvedValue(retired('forced')); expect(await stopDaemon({ paths: createDaemonPaths() })).toMatchObject({ stopped: true, mode: 'forced', @@ -133,10 +120,58 @@ test('confirmed KILL exit preserves unknown provider cleanup', async () => { test.each(['signal-failed', 'exit-timeout'])( '%s cannot become a successful stop', async (reason) => { - mocks.stopDaemonProcess.mockResolvedValue({ status: 'retained', reason }); + mocks.stopAndRetireDaemon.mockResolvedValue(retainedExit(reason)); await expect(stopDaemon({ paths: createDaemonPaths() })).rejects.toMatchObject({ code: 'COMMAND_FAILED', details: { reason: 'daemon_exit_unconfirmed', terminationReason: reason }, }); }, ); + +function retainedExit(reason: string) { + return { + status: 'retained', + reason: 'exit-unconfirmed', + removedInfo: false, + termination: { status: 'retained', reason }, + }; +} + +function retired(mode: string) { + return { + status: 'retired', + removedInfo: true, + termination: { status: 'exited', mode, identity: { pid: 123, startTime: 'start-time' } }, + }; +} + +test.each(['registration-replaced', 'retirement-unconfirmed'])( + '%s after confirmed exit cannot report a completed retirement', + async (reason) => { + const paths = createDaemonPaths(); + mocks.stopAndRetireDaemon.mockResolvedValue({ + ...retired('forced'), + status: 'retained', + reason, + removedInfo: false, + error: { + code: 'UNKNOWN', + message: 'retained', + hint: 'Inspect retained state.', + diagnosticId: 'diag-retire', + logPath: '/retained/daemon.log', + }, + }); + await expect(stopDaemon({ paths })).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { + reason: 'daemon_retirement_unconfirmed', + retirement: { status: 'retained', reason }, + hint: 'Inspect retained state.', + diagnosticId: 'diag-retire', + logPath: '/retained/daemon.log', + }, + }); + expect(fs.existsSync(paths.infoPath)).toBe(true); + }, +); 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__/focus-runtime.test.ts b/src/daemon/__tests__/focus-runtime.test.ts index 1d7f736966..7e4ea2f7fa 100644 --- a/src/daemon/__tests__/focus-runtime.test.ts +++ b/src/daemon/__tests__/focus-runtime.test.ts @@ -189,7 +189,7 @@ test('request router joins focus admission to execution, recording, and ref inva const sessionStore = makeSessionStore('agent-device-focus-generic-'); const session = makeSession('focus-runtime', { device: appleDevice }); activateCompleteRefFrame(session); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const handler = createRequestHandler({ logPath: '/tmp/daemon.log', token: 't', diff --git a/src/daemon/__tests__/generic-settle.test.ts b/src/daemon/__tests__/generic-settle.test.ts index fa0eb55985..38469c4ac8 100644 --- a/src/daemon/__tests__/generic-settle.test.ts +++ b/src/daemon/__tests__/generic-settle.test.ts @@ -13,7 +13,7 @@ import { activateCompleteRefFrame, refFrameState } from '../ref-frame.ts'; import { setSessionSnapshot } 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'; import { buildSnapshotState } from '@agent-device/capture-kit/snapshot-state'; // #1638 `--settle` on the GENERIC daemon route (scroll/back): the settled diff, @@ -85,19 +85,19 @@ type SettlePayload = { const captureObservations: Array<{ postGestureStabilizationPending: boolean }> = []; async function emulateCaptureSnapshotForSession( - session: SessionState, + ref: SessionRef, flags: CommandFlags | undefined, sessionStore: SessionStore, options: { interactiveOnly: boolean }, ) { captureObservations.push({ - postGestureStabilizationPending: session.postGestureStabilization !== undefined, + postGestureStabilizationPending: + sessionStore.requireCurrent(ref).postGestureStabilization !== undefined, }); const effectiveFlags = { ...(flags ?? {}), snapshotInteractiveOnly: options.interactiveOnly }; const snapshotData = (await mockDispatch('snapshot')) as Parameters[0]; const snapshot = buildSnapshotState(snapshotData ?? {}, effectiveFlags); - setSessionSnapshot(session, snapshot); - sessionStore.set(session.name, session); + setSessionSnapshot(sessionStore.requireCurrent(ref), snapshot); return snapshot; } @@ -140,7 +140,7 @@ function seedSession( const session = makeIosSession(sessionName); setSessionSnapshot(session, buildSnapshotState(snapshotPayload(baseline), {})); activateCompleteRefFrame(session); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); return session; } @@ -187,7 +187,7 @@ async function dispatchGeneric(params: { }; return await dispatchGenericCommand({ req, - session: params.session, + ref: params.sessionStore.lookup(params.sessionName)!, sessionName: params.sessionName, logPath: '', sessionStore: params.sessionStore, @@ -209,8 +209,8 @@ beforeEach(() => { mockCaptureSnapshotForSession.mockReset(); mockCaptureSnapshotForSession.mockImplementation( (...args: Parameters) => { - const [session, flags, sessionStore, _contextFromFlags, options] = args; - return emulateCaptureSnapshotForSession(session, flags, sessionStore, options); + const [ref, flags, sessionStore, _contextFromFlags, options] = args; + return emulateCaptureSnapshotForSession(ref, flags, sessionStore, options); }, ); }); @@ -549,32 +549,31 @@ test('scroll without --settle takes no observation captures and issues no refs', expect(refFrameState(sessionStore.get(sessionName) as SessionState)).toBe('expired'); }); -test('a settle observation that cannot build a runtime degrades instead of failing the action', async () => { +test('an ended generic lifetime is refused before dispatch or settle construction', async () => { const sessionStore = makeSessionStore(); const sessionName = 'generic-settle-evicted'; - // The session the router handed us is no longer in the store — evicted - // between dispatch and observation. Building the settle runtime throws - // SESSION_NOT_FOUND, and the observation is best-effort: the scroll already - // happened, so the response keeps its result and simply carries no settle. - const session = makeIosSession(sessionName); - setSessionSnapshot( - session, - buildSnapshotState({ nodes: BEFORE_NODES, backend: 'xctest', producer: 'apple-runner' }, {}), - ); - activateCompleteRefFrame(session); + seedSession(sessionName, sessionStore); + const ref = sessionStore.lookup(sessionName)!; + sessionStore.retire(ref); mockCommandDispatch([AFTER_NODES]); - - const response = await dispatchGeneric({ - sessionName, - sessionStore, - session, - command: 'scroll', - positionals: ['down'], - flags: { ...SETTLE_FLAGS }, - }); - - const data = expectOkData(response); - expect(data.settle).toBeUndefined(); + await expect( + dispatchGenericCommand({ + req: { + token: 't', + session: sessionName, + command: 'scroll', + positionals: ['down'], + flags: SETTLE_FLAGS, + }, + ref, + sessionName, + sessionStore, + logPath: '', + contextFromFlags, + executePlatformCommand: platformExecution, + }), + ).rejects.toMatchObject({ details: { reason: 'session_lifetime_ended' } }); + expect(mockDispatch).not.toHaveBeenCalled(); expect(captureObservations).toEqual([]); }); @@ -619,3 +618,91 @@ test('an orphaned --settle-quiet is rejected before the command dispatches', asy expect(response.error?.code).toBe('INVALID_ARGS'); expect(response.error?.message).toContain('--settle-quiet'); }); + +test('scroll stays successful when its lifetime retires during optional settle observation', async () => { + const store = makeSessionStore(); + const name = 'generic-settle-retired'; + const session = seedSession(name, store); + const ref = store.lookup(name)!; + mockCommandDispatch([AFTER_NODES]); + mockCaptureSnapshotForSession.mockImplementationOnce( + async (boundRef, flags, sessionStore, _context, options) => { + const observed = await emulateCaptureSnapshotForSession( + boundRef, + flags, + sessionStore, + options, + ); + queueMicrotask(() => { + store.retire(ref); + store.publish(name, makeIosSession(name)); + }); + return observed; + }, + ); + const response = await dispatchGeneric({ + sessionName: name, + sessionStore: store, + session, + command: 'scroll', + positionals: ['down'], + flags: { ...SETTLE_FLAGS }, + }); + expect(expectOkData(response).settle).toBeUndefined(); + expect(store.get(name)?.snapshot).toBeUndefined(); + expect(store.get(name)?.actions).toEqual([]); +}); + +for (const change of ['rebuild', 'retire'] as const) { + test(`held generic settle capture respects its lifetime after ${change}`, async () => { + const store = makeSessionStore(); + const name = `generic-held-settle-${change}`; + const session = seedSession(name, store); + const ref = store.lookup(name)!; + let entered!: () => void; + let release!: () => void; + const reached = new Promise((resolve) => { + entered = resolve; + }); + const held = new Promise((resolve) => { + release = resolve; + }); + mockDispatch.mockImplementation(async (command) => { + if (command !== 'snapshot') return {}; + entered(); + await held; + return snapshotPayload({ nodes: AFTER_NODES }); + }); + const pending = dispatchGeneric({ + sessionName: name, + sessionStore: store, + session, + command: 'scroll', + positionals: ['down'], + flags: { ...SETTLE_FLAGS }, + }); + try { + await reached; + const trace = { outPath: 'latest-trace', startedAt: 1 }; + const successor = makeIosSession(name); + if (change === 'rebuild') store.update(ref, { trace }); + else { + store.retire(ref); + store.publish(name, successor); + } + release(); + const data = expectOkData(await pending); + if (change === 'rebuild') { + expect(store.requireCurrent(ref).trace).toBe(trace); + expect(store.requireCurrent(ref).snapshot?.nodes[1]?.label).toBe('Load more'); + } else { + expect(data.settle).toBeUndefined(); + expect(store.get(name)).toBe(successor); + expect(successor.snapshot).toBeUndefined(); + } + } finally { + release(); + await pending; + } + }); +} diff --git a/src/daemon/__tests__/human-control-router-fixture.ts b/src/daemon/__tests__/human-control-router-fixture.ts index 8cbc7ee6d4..ddecdf3307 100644 --- a/src/daemon/__tests__/human-control-router-fixture.ts +++ b/src/daemon/__tests__/human-control-router-fixture.ts @@ -13,7 +13,7 @@ export function createHumanControlHarness() { const lease = registry.allocateLease(HUMAN_CONTROL_LEASE_REQUEST); const sessionStore = makeSessionStore('agent-device-human-control-'); const sessionName = tenantScopedSessionName(lease.tenantId, 'takeover-test'); - sessionStore.set( + sessionStore.publish( sessionName, makeIosAppSession(sessionName, { lease: buildSessionLeaseFromRequest(humanControlRequest(lease), lease), diff --git a/src/daemon/__tests__/internal-observation.test.ts b/src/daemon/__tests__/internal-observation.test.ts index 29167cfafc..d798548777 100644 --- a/src/daemon/__tests__/internal-observation.test.ts +++ b/src/daemon/__tests__/internal-observation.test.ts @@ -42,11 +42,11 @@ function scenario() { const prior = snapshot('e1', 'Previously published'); setSessionSnapshot(session, prior); markSessionPartialRefsIssued(session, ['e1']); - sessionStore.set(sessionName, session); + const ref = sessionStore.publish(sessionName, session); const captured = snapshot('e2', 'Internal capture'); const authority = bindAuthority(sessionStore, sessionName); const stored = authority.store(captured); - return { sessionStore, sessionName, session, prior, captured, authority, ...stored }; + return { sessionStore, sessionName, ref, session, prior, captured, authority, ...stored }; } function publishCurrent(input: ReturnType, signal?: AbortSignal) { @@ -59,17 +59,8 @@ function publishCurrent(input: ReturnType, signal?: AbortSignal function bindAuthority(sessionStore: SessionStore, sessionName: string, signal?: AbortSignal) { return bindInternalObservationAuthority({ - sessionStore: { - get: () => sessionStore.get(sessionName), - update: (mutate) => { - const session = sessionStore.get(sessionName); - if (!session) return false; - mutate(session); - sessionStore.set(sessionName, session); - return true; - }, - }, - sessionName, + sessionStore, + ref: sessionStore.lookup(sessionName), ...(signal ? { signal } : {}), }); } @@ -173,12 +164,12 @@ test('runtime revision invalidates evidence even when the ref frame was already test('session close invalidates capture evidence', () => { const input = scenario(); - input.sessionStore.delete(input.sessionName); + input.sessionStore.retire(input.ref); expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' }); // Even restoring the exact same session object cannot revive evidence that // a stale finalization attempt already consumed. - input.sessionStore.set(input.sessionName, input.session); + input.sessionStore.publish(input.sessionName, input.session); expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' }); expect(refFrameScope(input.session)).toEqual(new Set(['e1'])); }); @@ -186,7 +177,8 @@ test('session close invalidates capture evidence', () => { test('same-name session replacement cannot inherit capture evidence', () => { const input = scenario(); const replacement = makeIosSession(input.sessionName, { appBundleId: 'com.example.app' }); - input.sessionStore.set(input.sessionName, replacement); + input.sessionStore.retire(input.ref); + input.sessionStore.publish(input.sessionName, replacement); expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' }); expect(refFrameTree(replacement)).toBeUndefined(); @@ -214,3 +206,25 @@ test('generation and ref projection must match the exact captured tree', () => { expect(refFrameScope(wrongGeneration.session)).toEqual(new Set(['e1'])); expect(refFrameScope(wrongRef.session)).toEqual(new Set(['e1'])); }); + +test('a same-lifetime rebuild preserves evidence and publishes into the latest record', () => { + const input = scenario(); + const current = input.sessionStore.update(input.ref, { appName: 'Rebuilt app' }); + expect(publishCurrent(input)).toEqual({ + published: true, + refsGeneration: input.refsGeneration, + refCount: 1, + }); + expect(current.appName).toBe('Rebuilt app'); + expect(refFrameState(current)).toBe('active'); + expect(refFrameScope(current)).toEqual(new Set(['e2'])); + expect(refFrameTree(current)).toBe(input.captured); +}); + +test('reusing the same record in a new lifetime cannot adopt unconsumed evidence', () => { + const input = scenario(); + input.sessionStore.retire(input.ref); + input.sessionStore.publish(input.sessionName, input.session); + expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' }); + expect(refFrameScope(input.session)).toEqual(new Set(['e1'])); +}); diff --git a/src/daemon/__tests__/is-runtime.test.ts b/src/daemon/__tests__/is-runtime.test.ts index 32c84ba5b3..68fb57ee65 100644 --- a/src/daemon/__tests__/is-runtime.test.ts +++ b/src/daemon/__tests__/is-runtime.test.ts @@ -74,7 +74,10 @@ function isRequest( test('an admitted is inspects once, binds once, and answers through the bound capture', async () => { const fixture = selectorCaptureFixture({ snapshot: () => buttonSnapshot() }); const sessionStore = makeSessionStore(); - sessionStore.set('is-bound', makeAndroidSession('is-bound', { appBundleId: 'com.example.app' })); + sessionStore.publish( + 'is-bound', + makeAndroidSession('is-bound', { appBundleId: 'com.example.app' }), + ); const response = await dispatchIsViaRuntime({ req: isRequest('is-bound', ['visible', 'id=auth_continue']), @@ -95,7 +98,7 @@ test('is absent uses the bound readAny capture for selector-first input without snapshot: () => ({ nodes: [], backend: 'xctest', producer: 'apple-runner' }), }); const sessionStore = makeSessionStore(); - sessionStore.set('is-absent', makeIosAppSession('is-absent')); + sessionStore.publish('is-absent', makeIosAppSession('is-absent')); const response = await dispatchIsViaRuntime({ req: isRequest('is-absent', ['label="Removed row"', 'absent']), @@ -133,7 +136,7 @@ test('is absent bypasses a cached no-match snapshot before evaluating the bound }), }); const sessionStore = makeSessionStore(); - sessionStore.set( + sessionStore.publish( 'is-absent-fresh', makeIosAppSession('is-absent-fresh', { snapshot: cachedAbsent }), ); @@ -168,7 +171,7 @@ test('is absent fails closed for a quality-less legacy iOS root-only capture', a }), }); const sessionStore = makeSessionStore(); - sessionStore.set('is-legacy-sparse', makeIosAppSession('is-legacy-sparse')); + sessionStore.publish('is-legacy-sparse', makeIosAppSession('is-legacy-sparse')); const response = await dispatchIsViaRuntime({ req: isRequest('is-legacy-sparse', ['absent', 'label="Removed row"']), @@ -200,7 +203,7 @@ test('is absent fails closed for a quality-less legacy iOS root-only capture', a test('is absent rejects depth and scope before binding with typed invalid arguments', async () => { const fixture = selectorCaptureFixture(); const sessionStore = makeSessionStore(); - sessionStore.set('is-absent-flags', makeIosAppSession('is-absent-flags')); + sessionStore.publish('is-absent-flags', makeIosAppSession('is-absent-flags')); for (const [flag, value] of [ ['snapshotScope', 'Login'], @@ -233,7 +236,7 @@ test('an unavailable capture fact refuses before any bind', async () => { // The watchOS sentinel shape: capability-supported today, no snapshot backend at the owner. const fixture = selectorCaptureFixture({ capture: unavailableCapture }); const sessionStore = makeSessionStore(); - sessionStore.set('is-refused', makeAndroidSession('is-refused', { appBundleId: 'com.a' })); + sessionStore.publish('is-refused', makeAndroidSession('is-refused', { appBundleId: 'com.a' })); const response = await dispatchIsViaRuntime({ req: isRequest('is-refused', ['visible', 'id=auth_continue']), @@ -262,7 +265,7 @@ test('an iOS session with no tracked app is refused with the open hint, not answ snapshot: () => buttonSnapshot(), }); const sessionStore = makeSessionStore(); - sessionStore.set('is-no-app', makeIosSession('is-no-app')); + sessionStore.publish('is-no-app', makeIosSession('is-no-app')); const response = await withTestDeviceInventory( {}, @@ -291,7 +294,7 @@ test('an iOS session WITH a tracked app still answers, so the refusal is the pla snapshot: () => buttonSnapshot(), }); const sessionStore = makeSessionStore(); - sessionStore.set('is-with-app', makeIosAppSession('is-with-app')); + sessionStore.publish('is-with-app', makeIosAppSession('is-with-app')); const response = await dispatchIsViaRuntime({ req: isRequest('is-with-app', ['visible', 'label=Continue']), @@ -308,7 +311,7 @@ test('an iOS session WITH a tracked app still answers, so the refusal is the pla test('an Android session with no tracked app proceeds, because the owner advertises the without-active-app capture', async () => { const fixture = selectorCaptureFixture({ snapshot: () => buttonSnapshot() }); const sessionStore = makeSessionStore(); - sessionStore.set('is-android-no-app', makeAndroidSession('is-android-no-app')); + sessionStore.publish('is-android-no-app', makeAndroidSession('is-android-no-app')); const response = await dispatchIsViaRuntime({ req: isRequest('is-android-no-app', ['visible', 'id=auth_continue']), @@ -329,7 +332,7 @@ test('an Android session with no tracked app proceeds, because the owner adverti test('a refused request reaches the device by no route at all', async () => { const fixture = selectorCaptureFixture({ capture: unavailableCapture }); const sessionStore = makeSessionStore(); - sessionStore.set('is-direct-refused', makeIosAppSession('is-direct-refused')); + sessionStore.publish('is-direct-refused', makeIosAppSession('is-direct-refused')); mockRunAppleRunnerCommand.mockResolvedValue({ found: true, nodes: [ @@ -389,7 +392,7 @@ test('a failing predicate answers COMMAND_FAILED from the bound capture', async }), }); const sessionStore = makeSessionStore(); - sessionStore.set('is-direct-false', makeIosAppSession('is-direct-false')); + sessionStore.publish('is-direct-false', makeIosAppSession('is-direct-false')); mockRunAppleRunnerCommand.mockResolvedValue({ found: true, text: 'Apple Account', @@ -444,7 +447,7 @@ test('a miss on a surface that never settled carries the unsettled fact, and the const sessionStore = makeSessionStore(); const session = makeIosAppSession('is-unsettled'); markDeferredInteractionOutcome({ session, command: 'scroll', positionals: [], flags: {} }); - sessionStore.set('is-unsettled', session); + sessionStore.publish('is-unsettled', session); const isVisible = () => dispatchIsViaRuntime({ req: isRequest('is-unsettled', ['visible', 'id=target']), @@ -486,7 +489,7 @@ test('a read after a scroll that moved nothing carries the no-effect outcome, an const sessionStore = makeSessionStore(); const session = makeIosAppSession('is-no-effect', { snapshot: makeSnapshotState([row]) }); markDeferredInteractionOutcome({ session, command: 'scroll', positionals: ['down'], flags: {} }); - sessionStore.set('is-no-effect', session); + sessionStore.publish('is-no-effect', session); const isVisible = () => dispatchIsViaRuntime({ req: isRequest('is-no-effect', ['visible', 'id=row']), diff --git a/src/daemon/__tests__/lease-lifecycle.test.ts b/src/daemon/__tests__/lease-lifecycle.test.ts index 8fce339a62..365db14d47 100644 --- a/src/daemon/__tests__/lease-lifecycle.test.ts +++ b/src/daemon/__tests__/lease-lifecycle.test.ts @@ -22,7 +22,7 @@ test('admitRequestLeaseForLockedScope heartbeats and stores admitted lease on th deviceKey: 'ios:SIM-001', clientId: 'client-a', }); - sessionStore.set( + sessionStore.publish( 'default', makeIosSession('default', { lease: { @@ -71,7 +71,7 @@ test('cleanupExpiredLeasedSession consumes expired lease and deletes the session expiresAt: lease.expiresAt, }, }); - sessionStore.set('default', session); + sessionStore.publish('default', session); now = 1_011; const teardownSession = vi.fn(async () => {}); @@ -83,7 +83,9 @@ test('cleanupExpiredLeasedSession consumes expired lease and deletes the session }); expect(cleaned).toBe(true); - expect(teardownSession).toHaveBeenCalledWith(session, 'default'); + expect(teardownSession).toHaveBeenCalledWith( + expect.objectContaining({ address: 'default', session }), + ); expect(sessionStore.get('default')).toBeUndefined(); expect(leaseRegistry.listActiveLeases()).toHaveLength(0); }); diff --git a/src/daemon/__tests__/open-device-contention-wait.test.ts b/src/daemon/__tests__/open-device-contention-wait.test.ts index 5b3749c60d..9c0bc8ac5d 100644 --- a/src/daemon/__tests__/open-device-contention-wait.test.ts +++ b/src/daemon/__tests__/open-device-contention-wait.test.ts @@ -56,7 +56,7 @@ function session(address: string): SessionState { function storeWithHolder(): SessionStore { const store = makeSessionStore('agent-device-open-wait-'); - store.set(HOLDER_ADDRESS, session(HOLDER_ADDRESS)); + store.publish(HOLDER_ADDRESS, session(HOLDER_ADDRESS)); return store; } @@ -161,7 +161,7 @@ test('only a fresh open with a budget and a resolved device gets a wait', () => ).toBeUndefined(); // An open onto a session that already exists is bound to a device nobody else is refused for. - sessionStore.set(OPENER_ADDRESS, session(OPENER_ADDRESS)); + sessionStore.publish(OPENER_ADDRESS, session(OPENER_ADDRESS)); expect( beginWait({ req: budget, @@ -193,7 +193,8 @@ test('a device that frees up ends the wait without claiming a spent budget', asy vi.useFakeTimers(); const req = openRequest({ waitMs: 30_000 }); const store = storeWithHolder(); - const release = setTimeout(() => store.delete(HOLDER_ADDRESS), 600); + const holder = store.lookup(HOLDER_ADDRESS)!; + const release = setTimeout(() => store.retire(holder), 600); await waitUntil( () => @@ -223,10 +224,12 @@ test('an open yields the device lock to a session that took the device after the })!; // A competing open puts its session on the device in the window between this open's look at the // free store and its first pass under the locks, and hands it back 300ms later. - setTimeout(() => store.delete(HOLDER_ADDRESS), 300); const trace = lockTrace({ onAcquire: (pass) => { - if (pass === 1) store.set(HOLDER_ADDRESS, session(HOLDER_ADDRESS)); + if (pass === 1) { + const holder = store.publish(HOLDER_ADDRESS, session(HOLDER_ADDRESS)); + setTimeout(() => store.retire(holder), 300); + } }, }); diff --git a/src/daemon/__tests__/orientation-runtime.test.ts b/src/daemon/__tests__/orientation-runtime.test.ts index 65be8bce62..218fca919f 100644 --- a/src/daemon/__tests__/orientation-runtime.test.ts +++ b/src/daemon/__tests__/orientation-runtime.test.ts @@ -195,7 +195,7 @@ test('request router joins orientation admission to execution and ref invalidati const sessionStore = makeSessionStore('agent-device-orientation-generic-'); const session = makeSession('orientation-runtime', { device: testDevice }); activateCompleteRefFrame(session); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const handler = createRequestHandler({ logPath: '/tmp/daemon.log', token: 't', diff --git a/src/daemon/__tests__/perf-capture-session-resource.test.ts b/src/daemon/__tests__/perf-capture-session-resource.test.ts index cd4010c381..de425c50ac 100644 --- a/src/daemon/__tests__/perf-capture-session-resource.test.ts +++ b/src/daemon/__tests__/perf-capture-session-resource.test.ts @@ -1,3 +1,4 @@ +import { bindSessionPerfCapture } from '../session-capture-binding.ts'; import { beforeEach, expect, test, vi } from 'vitest'; import { localRuntimeOwner } from '@agent-device/contracts/platform-runtime'; import { AppError } from '@agent-device/kernel/errors'; @@ -51,7 +52,7 @@ test('a perf stop whose pull failed re-collects the device-side trace the first createdAt: 1, actions: [], }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const capture = { action: 'start' as const, kind: 'perfetto' as const, @@ -102,6 +103,7 @@ test('a perf stop whose pull failed re-collects the device-side trace the first }, ); + const binding = bindSessionPerfCapture(sessionStore, sessionStore.lookup(sessionName)!); const started = await startAndroidPerfCapture(device, localRuntimeOwner('android'), { sessionId: sessionName, appId: capture.packageName, @@ -112,9 +114,7 @@ test('a perf stop whose pull failed re-collects the device-side trace the first }); await adoptStartedPerfCapture({ admissionLedger: createPerfCaptureAdmissionLedger(), - session, - sessionName, - sessionStore, + binding, device, owner: localRuntimeOwner('android'), fence, @@ -128,9 +128,7 @@ test('a perf stop whose pull failed re-collects the device-side trace the first const stop = () => finishLivePerfCapture({ intent: 'capture', - session: sessionStore.get(sessionName) ?? session, - sessionName, - sessionStore, + binding, }); await expect(stop()).rejects.toBe(pullFailure); diff --git a/src/daemon/__tests__/replay-divergence/session-replay-divergence-android-occlusion.test.ts b/src/daemon/__tests__/replay-divergence/session-replay-divergence-android-occlusion.test.ts index 6f03e1c8ff..0b35a54061 100644 --- a/src/daemon/__tests__/replay-divergence/session-replay-divergence-android-occlusion.test.ts +++ b/src/daemon/__tests__/replay-divergence/session-replay-divergence-android-occlusion.test.ts @@ -52,7 +52,7 @@ test.each([ const root = mkdtempForTestSync('agent-device-replay-divergence-qsshade-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set( + sessionStore.publish( sessionName, makeAndroidSession(sessionName, { appBundleId: 'com.google.android.deskclock' }), ); diff --git a/src/daemon/__tests__/replay-divergence/session-replay-divergence-capture-policy.test.ts b/src/daemon/__tests__/replay-divergence/session-replay-divergence-capture-policy.test.ts index 8bb184e656..fd0ed718de 100644 --- a/src/daemon/__tests__/replay-divergence/session-replay-divergence-capture-policy.test.ts +++ b/src/daemon/__tests__/replay-divergence/session-replay-divergence-capture-policy.test.ts @@ -61,7 +61,7 @@ test('buildReplayFailureDivergence: routes through the freshness-retry wrapper a baselineCount: 20, routeComparable: false, }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); // Capture 1: stale, near-empty dump (a single bare view — no hittable/label/ // id) → `sharp-drop` vs the 20-node baseline → triggers a retry. @@ -149,7 +149,7 @@ test('buildReplayFailureDivergence: divergence capture drops the action snapshot const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; const appBundleId = 'com.callstack.agentdevicelab'; - sessionStore.set(sessionName, makeAndroidSession(sessionName, { appBundleId })); + sessionStore.publish(sessionName, makeAndroidSession(sessionName, { appBundleId })); mockDispatchCommand.mockReset(); mockDispatchCommand.mockResolvedValue({ diff --git a/src/daemon/__tests__/replay-divergence/session-replay-divergence-chrome-filter.test.ts b/src/daemon/__tests__/replay-divergence/session-replay-divergence-chrome-filter.test.ts index 96b8ce8153..df49dfb223 100644 --- a/src/daemon/__tests__/replay-divergence/session-replay-divergence-chrome-filter.test.ts +++ b/src/daemon/__tests__/replay-divergence/session-replay-divergence-chrome-filter.test.ts @@ -101,7 +101,10 @@ test('buildReplayFailureDivergence excludes keyboard chrome from screen.refs and const root = mkdtempForTestSync('agent-device-replay-divergence-keyboard-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); mockDispatchCommand.mockResolvedValue({ nodes: keyboardSwampedNodes('Push Article'), @@ -209,7 +212,10 @@ test('buildReplayFailureDivergence drops unlabeled non-interactive structural no const root = mkdtempForTestSync('agent-device-replay-divergence-structural-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); mockDispatchCommand.mockResolvedValue({ nodes: structuralNoiseNodes(), @@ -321,7 +327,10 @@ test('buildReplayFailureDivergence keeps an app inputAccessoryView control in sc const root = mkdtempForTestSync('agent-device-replay-divergence-accessory-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); mockDispatchCommand.mockResolvedValue({ nodes: keyboardWithAccessoryNodes(), @@ -373,7 +382,7 @@ test('buildReplayFailureDivergence excludes Android status-bar/IME chrome from s const root = mkdtempForTestSync('agent-device-replay-divergence-android-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set( + sessionStore.publish( sessionName, makeAndroidSession(sessionName, { appBundleId: 'com.callstack.agentdevicelab' }), ); diff --git a/src/daemon/__tests__/replay-divergence/session-replay-divergence-observation.test.ts b/src/daemon/__tests__/replay-divergence/session-replay-divergence-observation.test.ts index a5a4cfd878..ada39067fe 100644 --- a/src/daemon/__tests__/replay-divergence/session-replay-divergence-observation.test.ts +++ b/src/daemon/__tests__/replay-divergence/session-replay-divergence-observation.test.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import { beforeEach, expect, test, vi } from 'vitest'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; import { AppError } from '@agent-device/kernel/errors'; +import { sleep } from '@agent-device/host-kit/retry'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; import { SessionStore } from '../../session-store.ts'; import { captureDivergenceObservation } from '@agent-device/replay-port/session-replay-divergence'; @@ -29,7 +30,10 @@ vi.mock('@agent-device/host-kit/retry', async (importOriginal) => { }); const mockDispatchCommand = legacyDispatchCapture; -beforeEach(() => resetLegacySnapshotCapture(vi.mocked(captureSnapshotWithInteractor))); +beforeEach(() => { + resetLegacySnapshotCapture(vi.mocked(captureSnapshotWithInteractor)); + vi.mocked(sleep).mockReset().mockResolvedValue(undefined); +}); // #1385 P2: the retry deadline is a DELAY-ONLY budget, not a per-attempt // capture timeout — this loop does not itself bound how long a single @@ -46,7 +50,10 @@ test('captureDivergenceObservation retryLaunchRace: the 12s deadline bounds retr const root = mkdtempForTestSync('agent-device-replay-divergence-deadline-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); mockDispatchCommand.mockImplementation(async () => { vi.setSystemTime(new Date(Date.now() + 5_000)); @@ -82,3 +89,111 @@ test('captureDivergenceObservation retryLaunchRace: the 12s deadline bounds retr vi.useRealTimers(); } }); + +test.each(['rebuild', 'retire'] as const)( + 'divergence capture binds observation authority before awaiting the native capture: %s', + async (change) => { + const root = mkdtempForTestSync('agent-device-divergence-lifetime-'); + const store = new SessionStore(path.join(root, 'sessions')); + const session = makeIosSession('default', { appBundleId: 'com.example.app' }); + const ref = store.publish('cwd:worktree:default', session); + const replay = replayDivergenceForTest(store, ref.address); + let captured!: (value: Record) => void; + let started!: () => void; + const capturing = new Promise((resolve) => { + started = resolve; + }); + mockDispatchCommand.mockImplementationOnce( + () => + new Promise((resolve) => { + captured = resolve; + started(); + }), + ); + const pending = captureDivergenceObservation({ + session: replay.session!, + observationStore: replay.observationStore, + logPath: path.join(root, 'daemon.log'), + action: { command: 'click', positionals: ['label="Save"'], flags: {} }, + }); + await capturing; + let current; + if (change === 'rebuild') current = store.update(ref, { appName: 'Latest app' }); + else { + store.retire(ref); + current = makeIosSession('default', { appBundleId: 'com.example.successor' }); + store.publish(ref.address, current); + } + captured({ nodes: [{ index: 0, depth: 0, type: 'Button', ref: 'e2', label: 'Save' }] }); + const result = await pending; + expect(store.get(ref.address)).toBe(current); + expect(store.get('default')).toBeUndefined(); + if (change === 'rebuild') { + expect(result.state).toBe('available'); + expect(current.appName).toBe('Latest app'); + expect(current.snapshot?.nodes[0]?.label).toBe('Save'); + } else { + expect(result.state).toBe('unavailable'); + expect(current.snapshot).toBeUndefined(); + } + }, +); + +test.each(['rebuild', 'retire'] as const)( + 'capture retries retain their original lifetime through backoff: %s', + async (change) => { + const root = mkdtempForTestSync('agent-device-divergence-retry-lifetime-'); + const store = new SessionStore(path.join(root, 'sessions')); + const session = makeIosSession('default', { appBundleId: 'com.example.app' }); + const ref = store.publish('cwd:worktree:default', session); + const replay = replayDivergenceForTest(store, ref.address); + let sleeping!: () => void; + let resume!: () => void; + const backoff = new Promise((resolve) => { + sleeping = resolve; + }); + vi.mocked(sleep).mockImplementationOnce( + () => + new Promise((resolve) => { + resume = resolve; + sleeping(); + }), + ); + mockDispatchCommand + .mockResolvedValueOnce({ + nodes: [], + quality: { state: 'sparse', backend: 'tree' }, + }) + .mockResolvedValueOnce({ + nodes: [{ index: 0, depth: 0, type: 'Button', ref: 'e2', label: 'Save' }], + }); + const pending = captureDivergenceObservation({ + session: replay.session!, + observationStore: replay.observationStore, + logPath: path.join(root, 'daemon.log'), + retryLaunchRace: true, + action: { command: 'click', positionals: ['label="Save"'], flags: {} }, + }); + await backoff; + let current; + if (change === 'rebuild') current = store.update(ref, { appName: 'Latest app' }); + else { + store.retire(ref); + current = makeIosSession('default', { appBundleId: 'com.example.successor' }); + store.publish(ref.address, current); + } + resume(); + const result = await pending; + expect(store.get(ref.address)).toBe(current); + if (change === 'rebuild') { + expect(mockDispatchCommand).toHaveBeenCalledTimes(2); + expect(result.state).toBe('available'); + expect(current.appName).toBe('Latest app'); + expect(current.snapshot?.nodes[0]?.label).toBe('Save'); + } else { + expect(mockDispatchCommand).toHaveBeenCalledTimes(1); + expect(result.state).toBe('unavailable'); + expect(current.snapshot).toBeUndefined(); + } + }, +); diff --git a/src/daemon/__tests__/replay-divergence/session-replay-divergence-overlay.test.ts b/src/daemon/__tests__/replay-divergence/session-replay-divergence-overlay.test.ts index b72ebe7b4d..29d55a2f24 100644 --- a/src/daemon/__tests__/replay-divergence/session-replay-divergence-overlay.test.ts +++ b/src/daemon/__tests__/replay-divergence/session-replay-divergence-overlay.test.ts @@ -49,7 +49,7 @@ test('buildReplayFailureDivergence: a system-overlay window survives into screen const root = mkdtempForTestSync('agent-device-replay-divergence-overlay-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set( + sessionStore.publish( sessionName, makeAndroidSession(sessionName, { appBundleId: 'com.callstack.agentdevicelab' }), ); @@ -163,7 +163,7 @@ test('buildReplayFailureDivergence: a fully-captured overlay dismiss-target enum const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; const appBundleId = 'com.callstack.agentdevicelab'; - sessionStore.set(sessionName, makeAndroidSession(sessionName, { appBundleId })); + sessionStore.publish(sessionName, makeAndroidSession(sessionName, { appBundleId })); mockDispatchCommand.mockResolvedValue({ nodes: appSwampWithTrailingOverlay(appBundleId), @@ -259,7 +259,7 @@ test('buildReplayFailureDivergence: when a system overlay mass-covers the app, t const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; const appBundleId = 'com.callstack.agentdevicelab'; - sessionStore.set(sessionName, makeAndroidSession(sessionName, { appBundleId })); + sessionStore.publish(sessionName, makeAndroidSession(sessionName, { appBundleId })); mockDispatchCommand.mockResolvedValue({ nodes: overlayMassCoveringApp(appBundleId), @@ -334,7 +334,7 @@ test('buildReplayFailureDivergence: a mass-covered app with no actionable overla const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; const appBundleId = 'com.callstack.agentdevicelab'; - sessionStore.set(sessionName, makeAndroidSession(sessionName, { appBundleId })); + sessionStore.publish(sessionName, makeAndroidSession(sessionName, { appBundleId })); mockDispatchCommand.mockResolvedValue({ nodes: bareScrimMassCoveringApp(appBundleId), @@ -384,7 +384,7 @@ test('buildReplayFailureDivergence: the partial ref frame authorizes exactly the const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; const appBundleId = 'com.callstack.agentdevicelab'; - sessionStore.set(sessionName, makeAndroidSession(sessionName, { appBundleId })); + sessionStore.publish(sessionName, makeAndroidSession(sessionName, { appBundleId })); mockDispatchCommand.mockReset(); mockDispatchCommand.mockResolvedValue({ diff --git a/src/daemon/__tests__/replay-divergence/session-replay-divergence-publication.test.ts b/src/daemon/__tests__/replay-divergence/session-replay-divergence-publication.test.ts index 916e7b5238..e11375756c 100644 --- a/src/daemon/__tests__/replay-divergence/session-replay-divergence-publication.test.ts +++ b/src/daemon/__tests__/replay-divergence/session-replay-divergence-publication.test.ts @@ -43,7 +43,7 @@ function scenario(refCount = 20) { }; setSessionSnapshot(session, prior); markSessionPartialRefsIssued(session, ['old']); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const replaySession = replaySessionForTest(sessionStore, sessionName); const snapshot: SnapshotState = { createdAt: 2, diff --git a/src/daemon/__tests__/replay-divergence/session-replay-divergence-suggestions.test.ts b/src/daemon/__tests__/replay-divergence/session-replay-divergence-suggestions.test.ts index eeef2819df..bea2d6185f 100644 --- a/src/daemon/__tests__/replay-divergence/session-replay-divergence-suggestions.test.ts +++ b/src/daemon/__tests__/replay-divergence/session-replay-divergence-suggestions.test.ts @@ -27,7 +27,10 @@ test('buildReplayFailureDivergence dedupes suggestions using the strongest basis const root = mkdtempForTestSync('agent-device-replay-suggest-dedupe-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); mockDispatchCommand.mockResolvedValue({ nodes: [ diff --git a/src/daemon/__tests__/replay-maestro/session-replay-maestro-error-projection.test.ts b/src/daemon/__tests__/replay-maestro/session-replay-maestro-error-projection.test.ts index a9593097bd..4fecc2aa32 100644 --- a/src/daemon/__tests__/replay-maestro/session-replay-maestro-error-projection.test.ts +++ b/src/daemon/__tests__/replay-maestro/session-replay-maestro-error-projection.test.ts @@ -20,7 +20,7 @@ test('a typed Maestro replay error keeps its recovery hint', async () => { const flowPath = path.join(root, 'flow.yaml'); fs.writeFileSync(flowPath, `${stringify({ appId: 'com.example.app' })}---\n${stringify([])}`); const sessionStore = new SessionStore(path.join(root, 'sessions')); - sessionStore.set('default', makeIosSession('default')); + sessionStore.publish('default', makeIosSession('default')); const req = { token: 'test-token', diff --git a/src/daemon/__tests__/replay-maestro/session-replay-maestro-failure.test.ts b/src/daemon/__tests__/replay-maestro/session-replay-maestro-failure.test.ts index 13b01bec0e..d3bc88955b 100644 --- a/src/daemon/__tests__/replay-maestro/session-replay-maestro-failure.test.ts +++ b/src/daemon/__tests__/replay-maestro/session-replay-maestro-failure.test.ts @@ -59,7 +59,7 @@ async function buildFailureScenario( const root = mkdtempForTestSync('agent-device-maestro-suggestions-'); const sessionName = 'default'; const sessionStore = new SessionStore(path.join(root, 'sessions')); - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const replaySession = replaySessionForTest(sessionStore, sessionName); mockDispatchCommand.mockResolvedValue({ nodes, truncated: false, backend: 'xctest' }); const failure = await captureMaestroFailure(command, path.join(root, 'flow.yaml')); @@ -177,7 +177,7 @@ test('typed Maestro failure diagnostics render expanded selector values without const root = mkdtempForTestSync('agent-device-maestro-expanded-selector-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const flowPath = path.join(root, 'flow.yaml'); const label = 'Continue checkout'; fs.writeFileSync( @@ -240,7 +240,7 @@ test('typed Maestro nested scopes retain resolved target values after unwind', a const root = mkdtempForTestSync('agent-device-maestro-nested-redaction-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const flowPath = path.join(root, 'flow.yaml'); const tracePath = path.join(root, 'replay-timing.ndjson'); const targetLabel = 'Nested checkout target'; @@ -336,7 +336,7 @@ test('typed Maestro renders flow-local values when static include resolution fai const root = mkdtempForTestSync('agent-device-maestro-include-redaction-'); const sessionName = 'default'; const sessionStore = new SessionStore(path.join(root, 'sessions')); - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const flowPath = path.join(root, 'flow.yaml'); const flowName = 'checkout-details'; fs.writeFileSync( diff --git a/src/daemon/__tests__/replay-maestro/session-replay-maestro-remote-evalscript.test.ts b/src/daemon/__tests__/replay-maestro/session-replay-maestro-remote-evalscript.test.ts index 933c71086e..2ad7742cdd 100644 --- a/src/daemon/__tests__/replay-maestro/session-replay-maestro-remote-evalscript.test.ts +++ b/src/daemon/__tests__/replay-maestro/session-replay-maestro-remote-evalscript.test.ts @@ -47,7 +47,7 @@ async function runWithNetworkFlag(publicNetworkOnly: boolean | undefined) { const root = mkdtempForTestSync('agent-device-maestro-remote-wire-'); const flowPath = writeFlow(root, 'flow.yaml'); const sessionStore = new SessionStore(path.join(root, 'sessions')); - sessionStore.set('default', makeIosSession('default')); + sessionStore.publish('default', makeIosSession('default')); const req = { token: 'test-token', diff --git a/src/daemon/__tests__/replay-maestro/session-replay-maestro-session-address.test.ts b/src/daemon/__tests__/replay-maestro/session-replay-maestro-session-address.test.ts index 0e0a7e8154..aa47b06842 100644 --- a/src/daemon/__tests__/replay-maestro/session-replay-maestro-session-address.test.ts +++ b/src/daemon/__tests__/replay-maestro/session-replay-maestro-session-address.test.ts @@ -22,7 +22,7 @@ test('a typed Maestro selector conflict names the session store key, not "defaul const sessionStore = new SessionStore(path.join(root, 'sessions')); const session = makeIosSession('default'); session.sessionScope = { kind: 'cwd', id: '8bea844ab16aa9b3' }; - sessionStore.set(SCOPED_ADDRESS, session); + sessionStore.publish(SCOPED_ADDRESS, session); const req = { token: 'test-token', diff --git a/src/daemon/__tests__/replay-maestro/session-replay-runtime-maestro-dispatch.test.ts b/src/daemon/__tests__/replay-maestro/session-replay-runtime-maestro-dispatch.test.ts index 6385f6edbf..36fd939d73 100644 --- a/src/daemon/__tests__/replay-maestro/session-replay-runtime-maestro-dispatch.test.ts +++ b/src/daemon/__tests__/replay-maestro/session-replay-runtime-maestro-dispatch.test.ts @@ -116,7 +116,7 @@ test('runReplayCommand reports snapshot diagnostics from per-action session samp ['snapshot', 'snapshot', 'snapshot', 'snapshot', 'snapshot', ''].join('\n'), ); const sessionStore = new SessionStore(path.join(root, 'state')); - sessionStore.set( + sessionStore.publish( 's', makeIosSession('s', { snapshotDiagnostics: { samples: [] }, @@ -180,7 +180,7 @@ test('runReplayCommand reports snapshot diagnostics on replay failure', async () ['snapshot', 'snapshot', 'snapshot', 'snapshot', 'click "Missing"', ''].join('\n'), ); const sessionStore = new SessionStore(path.join(root, 'state')); - sessionStore.set( + sessionStore.publish( 's', makeIosSession('s', { snapshotDiagnostics: { samples: [] }, diff --git a/src/daemon/__tests__/replay-maestro/session-replay-runtime-maestro-shell-artifacts.test.ts b/src/daemon/__tests__/replay-maestro/session-replay-runtime-maestro-shell-artifacts.test.ts index fafe08603d..0c58468436 100644 --- a/src/daemon/__tests__/replay-maestro/session-replay-runtime-maestro-shell-artifacts.test.ts +++ b/src/daemon/__tests__/replay-maestro/session-replay-runtime-maestro-shell-artifacts.test.ts @@ -46,7 +46,7 @@ test('runReplayCommand writes per-action timing events to active trace', async ( fs.writeFileSync(tracePath, ''); const sessionStore = new SessionStore(path.join(root, 'state')); - sessionStore.set('s', { + sessionStore.publish('s', { name: 's', device: { platform: 'apple', 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..2461acd073 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 @@ -76,7 +77,10 @@ test('a healed script survives repair + fresh-session replay: self-contained ope const root = mkdtempForTestSync('agent-device-replay-repair-accept-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); const filePath = writeReplayFile(root, [ 'open "Demo" --relaunch --platform ios --metro-port 8081', SAVE_ANNOTATION, @@ -113,7 +117,7 @@ test('a healed script survives repair + fresh-session replay: self-contained ope expect(session.actions.map((a) => a.command)).toEqual(['open']); // --- Agent presses the blessed @ref (record-and-heal): recorded live. --- - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'press', positionals: ['@e7'], flags: {}, @@ -140,7 +144,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'); @@ -162,7 +166,7 @@ test('a healed script survives repair + fresh-session replay: self-contained ope invoke: makeRecordingReplayInvoke({ sessionStore: freshSessionStore, sessionName: freshSessionName, - openReplacesSession: true, + openRebuildsActions: true, spy: invokedFresh, }), }); 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..33a6908f82 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 @@ -85,7 +86,10 @@ test('a record-and-heal divergence on the LAST step resumes with an empty tail a const root = mkdtempForTestSync('agent-device-replay-empty-tail-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); const evidence = await recordArticleEvidence(); const filePath = writeReplayFile(root, [ 'open "Demo" --relaunch', @@ -152,7 +156,7 @@ test('a record-and-heal divergence on the LAST step resumes with an empty tail a expect(session.actions.map((a) => a.command)).toEqual(['open']); // --- Agent performs the corrective press (blessed @ref), recorded live. --- - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'press', positionals: ['@e6'], flags: {}, @@ -188,7 +192,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); @@ -212,7 +216,10 @@ test('a manual divergence (unannotated action-failure) on the LAST step resumes const root = mkdtempForTestSync('agent-device-replay-empty-tail-manual-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); // No target-v1 annotation: an unannotated action-failure always routes to // the `manual` fail-safe (no recorded targetEvidence). const filePath = writeReplayFile(root, ['open "Demo" --relaunch', 'click label="Save"']); @@ -279,7 +286,7 @@ test('a manual divergence (unannotated action-failure) on the LAST step resumes // --- Agent performs the step's intent as a recorded action (blessed // @ref), recorded live. --- - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'press', positionals: ['@e6'], flags: {}, @@ -312,7 +319,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); @@ -326,7 +333,10 @@ test('a caution (identity-mismatch) divergence on the LAST step resumes with an const root = mkdtempForTestSync('agent-device-replay-empty-tail-caution-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); const SAVE_ANNOTATION = '# agent-device:target-v1 {"id":"save","role":"button","label":"Save","ancestry":[],"sibling":0,"viewportOrder":0,"verification":"verified"}'; const filePath = writeReplayFile(root, [ @@ -407,7 +417,7 @@ test('a caution (identity-mismatch) divergence on the LAST step resumes with an // --- Agent presses the actual (renamed) control via a blessed @ref, // recorded live — the record-and-heal-shaped repair for path (a) from // #1262 ("selector binds the wrong node on the right screen"). --- - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'press', positionals: ['@e6'], flags: {}, @@ -436,7 +446,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); @@ -450,7 +460,10 @@ test('--from N stays legal for a caution divergence even after the N + 1 empty-t const root = mkdtempForTestSync('agent-device-replay-empty-tail-caution-from-n-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); const SAVE_ANNOTATION = '# agent-device:target-v1 {"id":"save","role":"button","label":"Save","ancestry":[],"sibling":0,"viewportOrder":0,"verification":"verified"}'; const filePath = writeReplayFile(root, [ @@ -544,7 +557,10 @@ test('an unauthorized --from one past the plan end is rejected on an ARMED sessi const root = mkdtempForTestSync('agent-device-replay-empty-tail-state-repair-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); const evidence = await recordArticleEvidence(); const filePath = writeReplayFile(root, [ 'open "Demo" --relaunch', @@ -627,7 +643,10 @@ test('a stale --plan-digest on an empty-tail resume is rejected WITHOUT consumin const root = mkdtempForTestSync('agent-device-replay-empty-tail-digest-retry-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); const evidence = await recordArticleEvidence(); const filePath = writeReplayFile(root, [ 'open "Demo" --relaunch', @@ -658,7 +677,7 @@ test('a stale --plan-digest on an empty-tail resume is rejected WITHOUT consumin expect(divergence.resume.from).toBe(3); const session = sessionStore.get(sessionName)!; - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'press', positionals: ['@e6'], flags: {}, diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair-loop.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair-loop.test.ts index d5870193a2..efef45cdff 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-loop.test.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-loop.test.ts @@ -1,3 +1,4 @@ +import { storeSessionForTest } from '../../../__tests__/test-utils/store-factory.ts'; import { isSessionRecording } from '../../session-script-publication-capability.ts'; /** * ADR 0012 decision 6: `replay --save-script` arming (R1), the repair-run @@ -83,7 +84,7 @@ function setup(prefix: string, sessionOverrides = {}) { const root = mkdtempForTestSync(prefix); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, sessionOverrides)); + sessionStore.publish(sessionName, makeIosSession(sessionName, sessionOverrides)); return { root, sessionStore, sessionName, logPath: path.join(root, 'daemon.log') }; } @@ -134,7 +135,7 @@ test('R1/R2/R6: prefix steps get fresh evidence, corrective + resumed steps land expect(divergence.resume.from).toBe(3); // --- Agent performs the corrective action live (recorded). --- - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'press', positionals: ['@e9'], flags: {}, @@ -242,7 +243,7 @@ test('R2 bypass guard: a PLAIN full replay (no --save-script) on an armed sessio expect(sessionStore.get(sessionName)!.actions.length).toBe(armedActionCount); }); -test('R6 no amputation: a pre-populated session whose step-1 open REPLACES the session healed-slices exactly this run', async () => { +test('R6 no amputation: rebuilding the action list during open heals exactly this run', async () => { // Pre-seed with 2 prior, unrelated actions. const { root, sessionStore, sessionName, logPath } = setup( 'agent-device-replay-repair-amputate-', @@ -259,13 +260,12 @@ test('R6 no amputation: a pre-populated session whose step-1 open REPLACES the s 'click id="b"', ]); - // open REPLACES the session with a fresh `actions: []` one (the real - // new-session branch, session-open-surface.ts) — the case the old pre-loop - // boundary=N would amputate (slice(2) drops the healed open + first click). + // Resetting the action list would make the pre-loop boundary amputate the + // healed open and first click. The coordinator must use the rebuilt record. const invoke = makeRecordingReplayInvoke({ sessionStore, sessionName, - openReplacesSession: true, + openRebuildsActions: true, evidence: (req) => (req.command === 'click' ? freshEvidence('x', 'X') : undefined), }); @@ -381,13 +381,13 @@ test('a --no-record state-fix action never enters session.actions', () => { // Agent fixes app state with --no-record, then performs the real corrective // action (recorded). - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'press', positionals: ['100', '200'], flags: { noRecord: true }, result: {}, }); - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'press', positionals: ['@e9'], flags: {}, @@ -527,7 +527,7 @@ test('Fix 3: only the TERMINAL close is skipped during a repair — a mid-plan c sessionStore, sessionName, spy, - openReplacesSession: true, + openRebuildsActions: true, }); const response = await runReplayForTest({ @@ -578,7 +578,7 @@ test('Fix 3: a --from resume that lands on the terminal close skips it too, lett expect(divergence.resume.from).toBe(2); const session = sessionStore.get(sessionName)!; - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'press', positionals: ['@e9'], flags: {}, diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts index 1daa91397f..81a7d9fbdb 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts @@ -1,3 +1,4 @@ +import { storeSessionForTest } from '../../../__tests__/test-utils/store-factory.ts'; /** * #1271 stage 2 (ADR 0012 amendment): repair-segment default exclusion of * observation-only commands (`snapshot`/`get`/`is`/a read-only `find`), the @@ -138,7 +139,10 @@ function setup(prefix: string) { const root = mkdtempForTestSync(prefix); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); return { root, sessionStore, @@ -193,14 +197,14 @@ test('diagnostic get/is reads mid-repair are excluded from the healed script by // --- The agent explores mid-repair: a couple of diagnostic reads to locate // the renamed control. Both are observation-only and the session is // repair-armed, so per the default exclusion neither is appended. --- - ctx.sessionStore.recordAction(session, { + ctx.sessionStore.recordAction(storeSessionForTest(ctx.sessionStore, session), { command: 'get', positionals: ['attrs', '@e5'], flags: {}, result: { selectorChain: ['id="save-v2"'] }, interactiveObservation: true, }); - ctx.sessionStore.recordAction(session, { + ctx.sessionStore.recordAction(storeSessionForTest(ctx.sessionStore, session), { command: 'is', positionals: ['visible', 'id="save-v2"'], flags: {}, @@ -212,7 +216,7 @@ test('diagnostic get/is reads mid-repair are excluded from the healed script by // --- The agent performs the corrective press (blessed @ref), recorded live — // a mutating action is never observation-only, so it is unaffected. --- - ctx.sessionStore.recordAction(session, { + ctx.sessionStore.recordAction(storeSessionForTest(ctx.sessionStore, session), { command: 'press', positionals: ['@e7'], flags: {}, @@ -295,7 +299,7 @@ test("a --record'ed diagnostic read lands in the healed script (the diverged-ste // --- The agent's correction for the diverged step IS itself a read (the // wave-3 E3 shape): `get attrs` on the renamed control, explicitly forced // into the heal with `--record` since it would otherwise be excluded. --- - ctx.sessionStore.recordAction(session, { + ctx.sessionStore.recordAction(storeSessionForTest(ctx.sessionStore, session), { command: 'get', positionals: ['attrs', '@e7'], flags: { record: true }, @@ -374,14 +378,14 @@ test('empty-segment guard: a --from resume refuses with an actionable --record h // --- The agent ONLY inspects — never performs a corrective action. Both // reads are excluded from `session.actions`, so nothing was recorded in // this repair segment. --- - ctx.sessionStore.recordAction(session, { + ctx.sessionStore.recordAction(storeSessionForTest(ctx.sessionStore, session), { command: 'get', positionals: ['attrs', '@e5'], flags: {}, result: { selectorChain: ['id="save-v2"'] }, interactiveObservation: true, }); - ctx.sessionStore.recordAction(session, { + ctx.sessionStore.recordAction(storeSessionForTest(ctx.sessionStore, session), { command: 'find', positionals: ['id', 'save-v2', 'exists'], flags: {}, @@ -418,7 +422,7 @@ test('non-repair authoring recording is unchanged: a read in a fresh `open --sav session.scriptPublication = authoringPublication('armed'); expect(session.scriptPublication.kind).not.toBe('repair'); - ctx.sessionStore.recordAction(session, { + ctx.sessionStore.recordAction(storeSessionForTest(ctx.sessionStore, session), { command: 'get', positionals: ['attrs', '@e5'], flags: {}, 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..f618e7a798 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 @@ -93,6 +94,7 @@ test('BLOCKER 2 (new): a repair close whose PLATFORM close fails never commits a const { root, sessionStore, sessionName, logPath, leaseRegistry } = setup( 'agent-device-repair-transaction-platform-close-fail-', ); + sessionStore.retire(sessionStore.lookup(sessionName)!); const session = makeCompleteRepairSession(sessionStore, sessionName, root); const healedPath = path.join(root, 'flow.healed.ad'); @@ -174,6 +176,7 @@ test('BLOCKER 3 (second follow-up): a retry after a SUCCESSFUL platform close bu const { root, sessionStore, sessionName, logPath, leaseRegistry } = setup( 'agent-device-repair-transaction-close-idempotent-', ); + sessionStore.retire(sessionStore.lookup(sessionName)!); makeCompleteRepairSession(sessionStore, sessionName, root); const healedPath = path.join(root, 'flow.healed.ad'); // A prior COMPLETE (sentinel-marked) healed artifact already sits at the @@ -244,7 +247,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 +264,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. @@ -272,6 +275,7 @@ test('BLOCKER 3 (third follow-up): an untargeted close that performed NO platfor const { root, sessionStore, sessionName, logPath, leaseRegistry } = setup( 'agent-device-repair-transaction-close-identity-untargeted-', ); + sessionStore.retire(sessionStore.lookup(sessionName)!); makeCompleteRepairSession(sessionStore, sessionName, root); const healedPath = path.join(root, 'flow.healed.ad'); // A prior COMPLETE artifact already sits at the default path so the commit @@ -317,6 +321,7 @@ test('BLOCKER 3 (third follow-up): a retry targeting a DIFFERENT app than the su const { root, sessionStore, sessionName, logPath, leaseRegistry } = setup( 'agent-device-repair-transaction-close-identity-changed-target-', ); + sessionStore.retire(sessionStore.lookup(sessionName)!); makeCompleteRepairSession(sessionStore, sessionName, root); const healedPath = path.join(root, 'flow.healed.ad'); fs.writeFileSync( diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-force.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-force.test.ts index 83d6e03d60..034783ee9f 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-force.test.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-force.test.ts @@ -1,3 +1,4 @@ +import { storeSessionForTest } from '../../../__tests__/test-utils/store-factory.ts'; /** * ADR 0012 decision 6 repair-transaction `--force`/`--overwrite` semantics (#1258): a * `--save-script` target that already exists is refused at arm time, before any step runs, unless @@ -110,6 +111,7 @@ test('#1258: close --save-script --force overwrites an existing COMPLETE healed const { root, sessionStore, sessionName, logPath, leaseRegistry } = setup( 'agent-device-repair-transaction-force-overwrite-', ); + sessionStore.retire(sessionStore.lookup(sessionName)!); makeCompleteRepairSession(sessionStore, sessionName, root); // A prior COMPLETE (sentinel-marked) healed artifact already sits at the // default path — `--force` must overwrite it instead of refusing. @@ -244,7 +246,7 @@ test('#1258 preflight honors PERSISTED force: a --from continuation without --fo // The agent's corrective press (blessed @ref), recorded live. const session = sessionStore.get(sessionName)!; - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'press', positionals: ['@e7'], flags: {}, @@ -317,7 +319,7 @@ test('#1258 preflight is per-target: a --from continuation RETARGETING to an exi expect(sessionTargetPath(session)).toBe(targetA); expect(sessionTargetForce(session)).toBe(true); // The agent's corrective press. - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'press', positionals: ['@e7'], flags: {}, @@ -391,6 +393,7 @@ test('#1258 force is per-target: re-arming --save-script= WITHOUT --force dro 'agent-device-repair-transaction-retarget-clears-force-', ); // Armed and forced for target (flow.healed.ad). + sessionStore.retire(sessionStore.lookup(sessionName)!); const session = makeCompleteRepairSession(sessionStore, sessionName, root); session.scriptPublication = repairPublication('complete', { boundary: 0, @@ -436,6 +439,7 @@ test('#1258 force per-target, contrast: re-arming --save-script= WITH --force const { root, sessionStore, sessionName, logPath, leaseRegistry } = setup( 'agent-device-repair-transaction-retarget-force-overwrites-', ); + sessionStore.retire(sessionStore.lookup(sessionName)!); const session = makeCompleteRepairSession(sessionStore, sessionName, root); session.scriptPublication = repairPublication('complete', { boundary: 0, diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction.fixtures.ts b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction.fixtures.ts index eecc71704a..66e45ec990 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction.fixtures.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction.fixtures.ts @@ -32,7 +32,10 @@ export function setup(prefix: string) { const root = mkdtempForTestSync(prefix); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); return { root, sessionStore, @@ -74,6 +77,6 @@ export function makeCompleteRepairSession( }, ], }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); return session; } 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..8412b5609d 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 — @@ -157,7 +158,7 @@ test('end-to-end repair transaction: cold divergence stays alive, corrective res // --- Agent performs the corrective press (blessed @ref), recorded live. --- const session = sessionStore.get(sessionName)!; - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'press', positionals: ['@e7'], flags: {}, @@ -304,8 +305,8 @@ 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.delete(sessionName); + sessionStore.finalizeRepairTeardown(storeSessionForTest(sessionStore, session)); + sessionStore.retire(sessionStore.lookup(sessionName)!); expect(fs.existsSync(path.join(root, 'flow.healed.ad'))).toBe(false); const tombstone = sessionStore.readRepairTombstone(sessionName); @@ -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,8 +399,8 @@ 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.delete(sessionName); + sessionStore.finalizeRepairTeardown(sessionStore.lookup(sessionName)!); + sessionStore.retire(sessionStore.lookup(sessionName)!); expect(sessionStore.readRepairTombstone(sessionName)).toBeDefined(); // A `--from` continuation targeting the (now reaped) session must surface @@ -422,6 +423,7 @@ test('BLOCKER 2b/2c: a close whose commit FAILS (no-clobber) keeps the session f const { root, sessionStore, sessionName, logPath, leaseRegistry } = setup( 'agent-device-repair-transaction-commit-fail-', ); + sessionStore.retire(sessionStore.lookup(sessionName)!); makeCompleteRepairSession(sessionStore, sessionName, root); // A prior COMPLETE (sentinel-marked) healed artifact already sits at the // default path — the commit must refuse to clobber it. diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair.fixtures.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair.fixtures.test.ts new file mode 100644 index 0000000000..a3c8f2806b --- /dev/null +++ b/src/daemon/__tests__/replay-repair/session-replay-repair.fixtures.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from 'vitest'; +import { + makeIosSession, + authoringPublication, +} from '../../../__tests__/test-utils/session-factories.ts'; +import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts'; +import { makeRecordingReplayInvoke } from './session-replay-repair.fixtures.ts'; + +test('recording replay fixtures use the selected scoped journal without a public-name slot', async () => { + const store = makeSessionStore(); + const address = 'cwd:recording-fixture:default'; + const ref = store.publish( + address, + makeIosSession('default', { + scriptPublication: authoringPublication('armed'), + }), + ); + const invoke = makeRecordingReplayInvoke({ sessionStore: store, sessionName: address }); + expect( + await invoke({ + token: 'test', + session: 'default', + command: 'click', + positionals: ['1', '2'], + flags: {}, + }), + ).toMatchObject({ ok: true }); + await store.flushEvents(); + expect(store.requireCurrent(ref).actions).toHaveLength(1); + expect(store.readEvents(address).events).toHaveLength(1); + expect(store.lookup('default')).toBeUndefined(); + expect(store.readEvents('default').events).toEqual([]); +}); diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair.fixtures.ts b/src/daemon/__tests__/replay-repair/session-replay-repair.fixtures.ts index bfdc799b69..41efd58afa 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair.fixtures.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair.fixtures.ts @@ -1,4 +1,6 @@ +import { storeSessionForTest } from '../../../__tests__/test-utils/store-factory.ts'; import { isSessionRecording } from '../../session-script-publication-capability.ts'; +import { NO_SCRIPT_PUBLICATION } from '../../session-script-publication-state.ts'; /** * Shared fixtures for the ADR 0012 decision 6 repair-loop tests. The mock * `invoke` in these tests must ACTUALLY record via `sessionStore.recordAction` @@ -31,11 +33,10 @@ export type RecordingReplayInvokeConfig = { /** Records every request seen, in order — for asserting dispatch order/flags. */ spy?: DaemonRequest[]; /** - * When true, `open` REPLACES the session with a fresh `actions: []` one — - * mimicking `session-open-surface.ts`'s new-session branch. Default records - * onto the existing session (creating one only if none exists yet). + * Rebuild the same lifetime with an empty action list during open, exercising + * healed-slice boundaries when the underlying record changes. */ - openReplacesSession?: boolean; + openRebuildsActions?: boolean; /** * Steps that fail: return `{ ok: false }` WITHOUT recording — mimicking a * dispatch failure that never reaches `finalizeTouchInteraction`. Keyed by @@ -59,7 +60,7 @@ export function makeRecordingReplayInvoke(config: RecordingReplayInvokeConfig): } const session = resolveInvokeSession(config, req); const evidence = isSessionRecording(session) ? config.evidence?.(req) : undefined; - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session, config.sessionName), { command: req.command, positionals: req.positionals ?? [], flags: req.flags ?? {}, @@ -85,10 +86,13 @@ function isFailStep(failSteps: ReadonlySet | undefined, req: DaemonReque } function resolveInvokeSession(config: RecordingReplayInvokeConfig, req: DaemonRequest) { - const existing = config.sessionStore.get(config.sessionName); - const mustCreate = req.command === 'open' && (config.openReplacesSession || !existing); - if (!mustCreate && existing) return existing; + const ref = config.sessionStore.lookup(config.sessionName); + if (ref) { + return req.command === 'open' && config.openRebuildsActions + ? config.sessionStore.update(ref, { actions: [], scriptPublication: NO_SCRIPT_PUBLICATION }) + : config.sessionStore.requireCurrent(ref); + } const created = makeIosSession(config.sessionName); - config.sessionStore.set(config.sessionName, created); + config.sessionStore.publish(config.sessionName, created); return created; } diff --git a/src/daemon/__tests__/replay-runtime/application.test.ts b/src/daemon/__tests__/replay-runtime/application.test.ts index d72e748a3d..9dd0b598e6 100644 --- a/src/daemon/__tests__/replay-runtime/application.test.ts +++ b/src/daemon/__tests__/replay-runtime/application.test.ts @@ -102,7 +102,7 @@ test('replay routes native and Maestro sources through one application seam and for (const replayCase of cases) { const root = mkdtempForTestSync(`agent-device-replay-application-${replayCase.kind}-`); const sessionStore = new SessionStore(path.join(root, 'sessions')); - sessionStore.set('default', makeIosSession('default')); + sessionStore.publish('default', makeIosSession('default')); const req = await replayCase.buildRequest(root); const invoked: DaemonRequest[] = []; @@ -130,7 +130,7 @@ test('replay routes native and Maestro sources through one application seam and test('replay admits an annotated target before dispatching its mutation', async () => { const root = mkdtempForTestSync('agent-device-replay-application-admission-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); - sessionStore.set('default', makeIosSession('default', { appBundleId: 'com.example.app' })); + sessionStore.publish('default', makeIosSession('default', { appBundleId: 'com.example.app' })); const filePath = writeReplayFile(root, [ '# agent-device:target-v1 {"id":"save","role":"button","label":"Save","ancestry":[],"sibling":0,"viewportOrder":0,"verification":"verified"}', 'click label="Save"', @@ -168,7 +168,7 @@ test('replay admits an annotated target before dispatching its mutation', async test('replay projects the exact failure cause and retains artifacts from the failed step', async () => { const root = mkdtempForTestSync('agent-device-replay-application-failure-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); - sessionStore.set('default', makeIosSession('default')); + sessionStore.publish('default', makeIosSession('default')); const artifactPath = path.join(root, 'failure.png'); fs.writeFileSync(artifactPath, 'artifact'); const filePath = writeReplayFile(root, ['open "Demo"']); diff --git a/src/daemon/__tests__/replay-runtime/session-replay-runtime-artifact-ledger.test.ts b/src/daemon/__tests__/replay-runtime/session-replay-runtime-artifact-ledger.test.ts index 6417a461dd..c5f8e9045e 100644 --- a/src/daemon/__tests__/replay-runtime/session-replay-runtime-artifact-ledger.test.ts +++ b/src/daemon/__tests__/replay-runtime/session-replay-runtime-artifact-ledger.test.ts @@ -31,7 +31,10 @@ test('a mid-loop throw reports exactly the artifacts of the steps that ran befor const root = mkdtempForTestSync('agent-device-replay-artifact-ledger-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); // Two real files, so `collectReplayActionArtifactPaths`' existence check // keeps them (it drops any candidate path that is not a file on disk). @@ -87,7 +90,10 @@ test('a completed run reports every step’s artifacts through the threaded ledg const root = mkdtempForTestSync('agent-device-replay-artifact-ledger-ok-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); const firstArtifact = path.join(root, 'step-1.png'); const secondArtifact = path.join(root, 'step-2.png'); diff --git a/src/daemon/__tests__/replay-runtime/session-replay-runtime-binding.test.ts b/src/daemon/__tests__/replay-runtime/session-replay-runtime-binding.test.ts index 9e1273e9f9..e87d9667d7 100644 --- a/src/daemon/__tests__/replay-runtime/session-replay-runtime-binding.test.ts +++ b/src/daemon/__tests__/replay-runtime/session-replay-runtime-binding.test.ts @@ -81,7 +81,7 @@ test('typed Maestro keeps a port-only runtime digest stable after launch binds t metroPort: 8083, }); if (!request.runtime) throw new Error('open must carry effective runtime hints'); - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); sessionStore.setRuntimeHints(sessionName, request.runtime); return { ok: true, data: {} }; } diff --git a/src/daemon/__tests__/replay-runtime/session-replay-runtime-failure-response.test.ts b/src/daemon/__tests__/replay-runtime/session-replay-runtime-failure-response.test.ts index 46a45168ec..c08638c2b8 100644 --- a/src/daemon/__tests__/replay-runtime/session-replay-runtime-failure-response.test.ts +++ b/src/daemon/__tests__/replay-runtime/session-replay-runtime-failure-response.test.ts @@ -37,7 +37,7 @@ test('divergence cause and action strings pass through the central redactor at c const root = mkdtempForTestSync('agent-device-replay-divergence-redact-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['click "Save"']); mockDispatchCommand.mockRejectedValue(new Error('no device runner available')); @@ -71,7 +71,10 @@ test('a fill divergence never serializes the typed text at any response level', const root = mkdtempForTestSync('agent-device-replay-fill-leak-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); const filePath = writeReplayFile(root, [`fill 'label="Email"' ${JSON.stringify(sentinel)}`]); // Selector miss forces the divergence on the fill step; the failure // message is a realistic selector error, not an echo of the typed text. @@ -125,7 +128,10 @@ test('a capture-failed screen hint redacts a secret in the capture error', async const root = mkdtempForTestSync('agent-device-replay-screen-redact-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); const filePath = writeReplayFile(root, ['click "Save"']); // The post-failure snapshot capture throws with a secret-bearing message. mockDispatchCommand.mockRejectedValue(new Error('snapshot failed: api_key=sk-live-abc123def456')); @@ -159,7 +165,10 @@ test('an expanded ${VAR} value echoed by a selector error never reaches the publ const root = mkdtempForTestSync('agent-device-replay-var-leak-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); const filePath = writeReplayFile(root, ['press label="${SECRET}"']); mockDispatchCommand.mockRejectedValue(new Error('no device runner available')); @@ -203,7 +212,10 @@ test('an expanded built-in AD_DEVICE_ID never reaches the public divergence', as const sessionName = 'static-session-context'; const root = mkdtempForTestSync('agent-device-replay-builtin-var-leak-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); const filePath = writeReplayFile(root, ['press label="${AD_DEVICE_ID}"']); mockDispatchCommand.mockRejectedValue(new Error('no device runner available')); diff --git a/src/daemon/__tests__/replay-runtime/session-replay-runtime-failure.test.ts b/src/daemon/__tests__/replay-runtime/session-replay-runtime-failure.test.ts index d5333ef699..a8f83bc810 100644 --- a/src/daemon/__tests__/replay-runtime/session-replay-runtime-failure.test.ts +++ b/src/daemon/__tests__/replay-runtime/session-replay-runtime-failure.test.ts @@ -40,7 +40,10 @@ test('a failing replay step returns REPLAY_DIVERGENCE with cause preserved and c const root = mkdtempForTestSync('agent-device-replay-divergence-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); // The post-failure screen digest capture (and the suggestions re-resolution @@ -166,7 +169,10 @@ test('a normalized nested failure preserves typed recovery signals on REPLAY_DIV const root = mkdtempForTestSync('agent-device-replay-recovery-signals-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); const filePath = writeReplayFile(root, ['click "Save"']); mockDispatchCommand.mockRejectedValue(new Error('no device runner available')); @@ -201,7 +207,10 @@ test('a capture the runner declared sparse reaches the divergence details as a v const root = mkdtempForTestSync('agent-device-replay-sparse-verdict-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); const filePath = writeReplayFile(root, ['click "Not Now"']); mockDispatchCommand.mockRejectedValue(new Error('no device runner available')); @@ -243,7 +252,10 @@ test('a failing replay step captures an available screen digest with blessed ref const root = mkdtempForTestSync('agent-device-replay-divergence-screen-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); const filePath = writeReplayFile(root, ['click "Save"']); mockDispatchCommand.mockResolvedValue({ @@ -290,7 +302,10 @@ test('a failing replay step ranks a re-resolved suggestion when the recorded sel const root = mkdtempForTestSync('agent-device-replay-divergence-suggest-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); // The recorded selector is label-only, so it still structurally matches a // node in the fresh capture even though the underlying tap failed (e.g. the // node moved off-screen or was momentarily not hittable) — the exact class @@ -387,7 +402,7 @@ test('a failure inside a retry-wrapped runFlow include reports the include file const root = mkdtempForTestSync('agent-device-replay-retry-provenance-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const childPath = writeMaestroInclude(root); const mainPath = path.join(root, 'main.yaml'); fs.writeFileSync( @@ -444,7 +459,7 @@ test('a failure inside a runtime runFlow.when-wrapped include reports the includ const root = mkdtempForTestSync('agent-device-replay-when-provenance-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const childPath = writeMaestroInclude(root); const mainPath = path.join(root, 'main.yaml'); fs.writeFileSync( @@ -500,7 +515,7 @@ test('typed Maestro failures rank suggestions with Maestro regex selector semant const root = mkdtempForTestSync('agent-device-maestro-suggestion-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const flowPath = path.join(root, 'flow.yaml'); fs.writeFileSync( flowPath, @@ -556,7 +571,7 @@ test('typed Maestro failures never serialize input text', async () => { const root = mkdtempForTestSync('agent-device-maestro-text-redaction-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const flowPath = path.join(root, 'flow.yaml'); const typedText = 'highly-sensitive-value'; fs.writeFileSync( diff --git a/src/daemon/__tests__/replay-runtime/session-replay-runtime-keep-session.test.ts b/src/daemon/__tests__/replay-runtime/session-replay-runtime-keep-session.test.ts index 8de8149e7a..8ae4ab2f4b 100644 --- a/src/daemon/__tests__/replay-runtime/session-replay-runtime-keep-session.test.ts +++ b/src/daemon/__tests__/replay-runtime/session-replay-runtime-keep-session.test.ts @@ -53,7 +53,7 @@ test('--keep-session suppresses a close that is terminal among executable action const root = mkdtempForTestSync('agent-device-replay-keep-marker-tail-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'close', 'replay "./nested-flow.ad"']); const commands: string[] = []; @@ -64,7 +64,7 @@ test('--keep-session suppresses a close that is terminal among executable action sessionStore, invoke: async (req) => { commands.push(req.command); - if (req.command === 'close') sessionStore.delete(sessionName); + if (req.command === 'close') sessionStore.retire(sessionStore.lookup(sessionName)!); return { ok: true, data: {} }; }, }); @@ -79,7 +79,7 @@ test('--keep-session fails explicitly when the completed replay has no live sess const root = mkdtempForTestSync('agent-device-replay-keep-postcondition-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Log out"']); const response = await runReplayForTest({ @@ -88,7 +88,7 @@ test('--keep-session fails explicitly when the completed replay has no live sess logPath: path.join(root, 'daemon.log'), sessionStore, invoke: async (req) => { - if (req.command === 'click') sessionStore.delete(sessionName); + if (req.command === 'click') sessionStore.retire(sessionStore.lookup(sessionName)!); return { ok: true, data: {} }; }, }); @@ -106,7 +106,7 @@ test('--keep-session suppresses only the authored terminal close and reports the const root = mkdtempForTestSync('agent-device-replay-keep-session-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"', 'close']); const commands: string[] = []; @@ -117,7 +117,7 @@ test('--keep-session suppresses only the authored terminal close and reports the sessionStore, invoke: async (req) => { commands.push(req.command); - if (req.command === 'close') sessionStore.delete(sessionName); + if (req.command === 'close') sessionStore.retire(sessionStore.lookup(sessionName)!); return { ok: true, data: {} }; }, }); @@ -133,7 +133,7 @@ test('--keep-session preserves an interior close instead of broad command filter const root = mkdtempForTestSync('agent-device-replay-keep-interior-close-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'close', 'open "Next"']); const commands: string[] = []; @@ -156,7 +156,7 @@ test('--keep-session is a no-op for an already close-less script', async () => { const root = mkdtempForTestSync('agent-device-replay-keep-close-less-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); const invoke = vi.fn(async (_req: DaemonRequest) => ({ ok: true as const, data: {} })); @@ -178,7 +178,7 @@ test('--keep-session rejects Maestro YAML before engine dispatch', async () => { const root = mkdtempForTestSync('agent-device-replay-keep-maestro-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = path.join(root, 'flow.yaml'); fs.writeFileSync(filePath, ['appId: com.example.app', '---', '- launchApp'].join('\n')); const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); diff --git a/src/daemon/__tests__/replay-runtime/session-replay-runtime-plan.test.ts b/src/daemon/__tests__/replay-runtime/session-replay-runtime-plan.test.ts index 9ae7567682..301c8794b3 100644 --- a/src/daemon/__tests__/replay-runtime/session-replay-runtime-plan.test.ts +++ b/src/daemon/__tests__/replay-runtime/session-replay-runtime-plan.test.ts @@ -47,7 +47,7 @@ test('resume skips steps 1..from-1 without invoking them and executes only from const root = mkdtempForTestSync('agent-device-replay-resume-skip-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, [ 'open "Demo"', 'click label="Continue"', @@ -106,7 +106,7 @@ test('resume requires both --from and --plan-digest together', async () => { const root = mkdtempForTestSync('agent-device-replay-resume-pair-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); const fromOnly = await runReplayForTest({ @@ -138,7 +138,7 @@ test('resume rejects an out-of-range --from before any action', async () => { const root = mkdtempForTestSync('agent-device-replay-resume-range-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); const response = await runReplayForTest({ @@ -173,7 +173,7 @@ test("a rejected --from/--plan-digest resume never reaches prepareReplaySession' const root = mkdtempForTestSync('agent-device-replay-resume-no-mutate-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Continue"', 'click "Save"']); // Arm a repair transaction and stamp a corrective-resume watermark @@ -189,7 +189,6 @@ test("a rejected --from/--plan-digest resume never reaches prepareReplaySession' // from firing first (it needs `sessionActionsLength` to equal this), so // the rejection below is provably the plan-digest check, not a different one. armedSession.pendingRecordAndHeal = { expectedFrom: 2, actionsCountAtDivergence: 999 }; - sessionStore.set(sessionName, armedSession); const beforeView = coordinator.view(); const beforeActionsLength = sessionStore.get(sessionName)!.actions.length; @@ -223,7 +222,7 @@ test('resume rejects a stale --plan-digest after the script changed', async () = const root = mkdtempForTestSync('agent-device-replay-resume-stale-digest-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); const firstAttempt = await runReplayForTest({ @@ -270,7 +269,7 @@ test('resume rejects a digest from a different effective platform or target befo const root = mkdtempForTestSync('agent-device-replay-resume-effective-target-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, [ 'context platform=android target=tv', 'open "Demo"', @@ -326,7 +325,7 @@ test('resume rejects resuming past a retry-wrapped step in the skipped range', a const root = mkdtempForTestSync('agent-device-replay-resume-control-flow-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const mainPath = path.join(root, 'main.yaml'); fs.writeFileSync( mainPath, @@ -396,7 +395,7 @@ test('typed Maestro resume digest binds an inferred session target', async () => const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; const session = makeIosSession(sessionName); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const flowPath = path.join(root, 'flow.yaml'); fs.writeFileSync(flowPath, 'appId: com.example.app\n---\n- back\n'); @@ -416,10 +415,9 @@ test('typed Maestro resume digest binds an inferred session target', async () => resume: { from: number; planDigest: string }; }; - sessionStore.set(sessionName, { - ...session, - device: { ...session.device, target: 'tv' }, - }); + sessionStore.update(sessionStore.lookup(sessionName)!, (current) => ({ + device: { ...current.device, target: 'tv' }, + })); const resumedAttempt = await runReplayForTest({ req: baseReq({ positionals: [flowPath], @@ -447,7 +445,7 @@ test('typed Maestro rejects selectors that conflict with an active session', asy const root = mkdtempForTestSync('agent-device-maestro-session-conflict-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const flowPath = path.join(root, 'flow.yaml'); fs.writeFileSync(flowPath, 'appId: com.example.app\n---\n- back\n'); const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); @@ -573,7 +571,7 @@ test('typed Maestro resume digest binds effective stored runtime hints', async ( const root = mkdtempForTestSync('agent-device-maestro-session-runtime-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); sessionStore.setRuntimeHints(sessionName, { platform: 'ios', metroHost: '127.0.0.1', diff --git a/src/daemon/__tests__/replay-runtime/session-replay-runtime.fixtures.ts b/src/daemon/__tests__/replay-runtime/session-replay-runtime.fixtures.ts index d7d47319c0..32300cd8ed 100644 --- a/src/daemon/__tests__/replay-runtime/session-replay-runtime.fixtures.ts +++ b/src/daemon/__tests__/replay-runtime/session-replay-runtime.fixtures.ts @@ -52,6 +52,6 @@ export function seedReplayFixtureSession( sessionStore: SessionStore, platform: 'android' | 'ios' | undefined, ): void { - if (platform === 'android') sessionStore.set('s', makeAndroidSession('s')); - if (platform === 'ios') sessionStore.set('s', makeIosSession('s')); + if (platform === 'android') sessionStore.publish('s', makeAndroidSession('s')); + if (platform === 'ios') sessionStore.publish('s', makeIosSession('s')); } diff --git a/src/daemon/__tests__/replay-runtime/session-replay-runtime.test.ts b/src/daemon/__tests__/replay-runtime/session-replay-runtime.test.ts index 15f5460ac2..bdaced7b33 100644 --- a/src/daemon/__tests__/replay-runtime/session-replay-runtime.test.ts +++ b/src/daemon/__tests__/replay-runtime/session-replay-runtime.test.ts @@ -43,7 +43,7 @@ test('a successful replay prints one line with the step count and wall time', as const root = mkdtempForTestSync('agent-device-replay-success-message-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); const response = await runReplayForTest({ @@ -72,7 +72,7 @@ test('a close-less replay reports sessionActive: true (real producer, session st const root = mkdtempForTestSync('agent-device-replay-session-active-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); const response = await runReplayForTest({ @@ -93,7 +93,7 @@ test('a replay whose terminal close removes the session reports sessionActive: f const root = mkdtempForTestSync('agent-device-replay-session-closed-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'close']); const response = await runReplayForTest({ @@ -105,7 +105,7 @@ test('a replay whose terminal close removes the session reports sessionActive: f // removing the session from the store. Every other command is a no-op, // same as the rest of this file's `invoke` stubs. invoke: async (req) => { - if (req.command === 'close') sessionStore.delete(sessionName); + if (req.command === 'close') sessionStore.retire(sessionStore.lookup(sessionName)!); return { ok: true, data: {} }; }, }); @@ -120,7 +120,7 @@ test('Maestro YAML uses the typed engine while .ad remains generic', async () => const root = mkdtempForTestSync('agent-device-typed-maestro-route-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const yamlPath = path.join(root, 'flow.yaml'); fs.writeFileSync( yamlPath, @@ -169,7 +169,7 @@ test('bare Maestro YAML requires explicit --maestro routing', async () => { const root = mkdtempForTestSync('agent-device-maestro-explicit-route-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const yamlPath = path.join(root, 'flow.yaml'); fs.writeFileSync(yamlPath, 'appId: com.example.app\n---\n- launchApp\n'); @@ -194,7 +194,7 @@ test('ADR 0016 / #1384: a Maestro replay reports sessionActive: true (real produ const root = mkdtempForTestSync('agent-device-maestro-session-active-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const yamlPath = path.join(root, 'flow.yaml'); fs.writeFileSync(yamlPath, 'appId: com.example.app\n---\n- launchApp\n'); @@ -220,7 +220,7 @@ test('typed Maestro nested commands receive the runtime hints bound into the pla const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; const session = makeIosSession(sessionName); - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...session, device: { ...session.device, simulatorSetPath: '/tmp/custom-simulator-set' }, }); @@ -269,7 +269,7 @@ test('typed Maestro writes source-aware redacted step timing traces', async () = const root = mkdtempForTestSync('agent-device-maestro-step-trace-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const yamlPath = path.join(root, 'flow.yaml'); const tracePath = path.join(root, 'replay-timing.ndjson'); fs.writeFileSync(yamlPath, 'appId: com.example.app\n---\n- inputText: highly-sensitive\n'); @@ -329,7 +329,7 @@ test('replay trace failures do not change action semantics', async () => { const root = mkdtempForTestSync('agent-device-replay-trace-failure-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const yamlPath = path.join(root, 'flow.yaml'); fs.writeFileSync(yamlPath, 'appId: com.example.app\n---\n- back\n'); @@ -352,7 +352,7 @@ test('generic replay traces redact typed text', async () => { const root = mkdtempForTestSync('agent-device-replay-trace-redaction-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const secret = 'highly-sensitive-value'; const filePath = writeReplayFile(root, [`type "${secret}"`]); const tracePath = path.join(root, 'replay-timing.ndjson'); @@ -378,7 +378,7 @@ test('Maestro YAML rejects .ad repair recording before executing any command', a const root = mkdtempForTestSync('agent-device-typed-maestro-save-script-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const yamlPath = path.join(root, 'flow.yaml'); fs.writeFileSync(yamlPath, 'appId: com.example.app\n---\n- launchApp\n'); const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); @@ -407,7 +407,7 @@ test('Maestro YAML cannot append commands to an active .ad repair session', asyn const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; const session = makeRepairArmedSession(sessionName); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const yamlPath = path.join(root, 'flow.yaml'); fs.writeFileSync(yamlPath, 'appId: com.example.app\n---\n- launchApp\n'); const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); @@ -433,7 +433,7 @@ test('replay rejects legacy JSON payload files', async () => { const root = mkdtempForTestSync('agent-device-replay-json-rejected-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = path.join(root, 'replay.json'); fs.writeFileSync(filePath, JSON.stringify({ optimizedActions: [] }, null, 2)); @@ -466,7 +466,7 @@ test('replay rejects an unknown --replay-backend value before any step dispatch const root = mkdtempForTestSync('agent-device-replay-unknown-backend-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); @@ -495,7 +495,7 @@ test('replay still dispatches a plain .ad script with replayBackend: "maestro"', const root = mkdtempForTestSync('agent-device-replay-ad-maestro-backend-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); @@ -517,7 +517,7 @@ test('replay rejects malformed .ad lines with unclosed quotes', async () => { const root = mkdtempForTestSync('agent-device-replay-invalid-ad-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, [String.raw`click "id=\"broken\"`]); const response = await runReplayForTest({ @@ -540,7 +540,10 @@ test('--update never rewrites the .ad file, even when a re-resolvable suggestion const root = mkdtempForTestSync('agent-device-replay-update-no-write-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); const filePath = writeReplayFile(root, ['click label="Save"']); const before = fs.readFileSync(filePath, 'utf8'); const statBefore = fs.statSync(filePath); @@ -590,7 +593,7 @@ test('a successful --update replay reports healed: 0 (heal is retired, not just const root = mkdtempForTestSync('agent-device-replay-update-healed-zero-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); const response = await runReplayForTest({ @@ -611,7 +614,7 @@ test('--update no longer refuses env directives (the guard existed only for rewr const root = mkdtempForTestSync('agent-device-replay-update-env-ok-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['env NAME=World', 'open "Demo"']); const response = await runReplayForTest({ @@ -629,7 +632,7 @@ test('--update no longer refuses ${VAR} interpolation (the guard existed only fo const root = mkdtempForTestSync('agent-device-replay-update-interp-ok-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['click label="${NAME}"']); const response = await runReplayForTest({ diff --git a/src/daemon/__tests__/replay-runtime/session-replay-script-source.test.ts b/src/daemon/__tests__/replay-runtime/session-replay-script-source.test.ts index 41aa965eef..555fc33bcf 100644 --- a/src/daemon/__tests__/replay-runtime/session-replay-script-source.test.ts +++ b/src/daemon/__tests__/replay-runtime/session-replay-script-source.test.ts @@ -45,7 +45,7 @@ beforeEach(() => { test('a bundled replay runs after the script file is gone from the daemon host', async () => { const root = mkdtempForTestSync('agent-device-replay-source-bundle-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); - sessionStore.set('default', makeIosSession('default')); + sessionStore.publish('default', makeIosSession('default')); const scriptPath = path.join(root, 'flow.ad'); fs.writeFileSync(scriptPath, 'open "Demo"\nclick "Save"\n'); const bundle = replayScriptSourceBundleFor(scriptPath); @@ -75,7 +75,7 @@ test('a bundled replay runs after the script file is gone from the daemon host', test('a bundled Maestro replay resolves runFlow includes from the bundle, not the daemon disk', async () => { const root = mkdtempForTestSync('agent-device-replay-maestro-bundle-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); - sessionStore.set('default', makeIosSession('default')); + sessionStore.publish('default', makeIosSession('default')); const flowPath = path.join(root, 'flow.yaml'); fs.writeFileSync(flowPath, 'appId: com.example.app\n---\n- runFlow: included.yaml\n'); fs.writeFileSync(path.join(root, 'included.yaml'), '---\n- back\n'); @@ -107,7 +107,7 @@ test('a bundled Maestro replay resolves runFlow includes from the bundle, not th test('a Maestro include missing from the bundle fails naming the include path', async () => { const root = mkdtempForTestSync('agent-device-replay-maestro-missing-include-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); - sessionStore.set('default', makeIosSession('default')); + sessionStore.publish('default', makeIosSession('default')); const flowPath = path.join(root, 'flow.yaml'); const flow = 'appId: com.example.app\n---\n- runFlow: absent.yaml\n'; fs.writeFileSync(flowPath, flow); @@ -141,7 +141,7 @@ test('a Maestro include missing from the bundle fails naming the include path', test('a request carrying only a path is refused with the client-must-send-sources message', async () => { const root = mkdtempForTestSync('agent-device-replay-path-only-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); - sessionStore.set('default', makeIosSession('default')); + sessionStore.publish('default', makeIosSession('default')); const scriptPath = path.join(root, 'flow.ad'); fs.writeFileSync(scriptPath, 'open "Demo"\n'); @@ -171,7 +171,7 @@ test('a request carrying only a path is refused with the client-must-send-source test('a failing step reports the caller-resolved script path and line', async () => { const root = mkdtempForTestSync('agent-device-replay-source-display-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); - sessionStore.set('default', makeIosSession('default')); + sessionStore.publish('default', makeIosSession('default')); const scriptPath = path.join(root, 'flow.ad'); fs.writeFileSync(scriptPath, 'open "Demo"\nclick "Nope"\n'); diff --git a/src/daemon/__tests__/replay-suite/session-test-runner.test.ts b/src/daemon/__tests__/replay-suite/session-test-runner.test.ts index 6275640088..a8630547f2 100644 --- a/src/daemon/__tests__/replay-suite/session-test-runner.test.ts +++ b/src/daemon/__tests__/replay-suite/session-test-runner.test.ts @@ -31,7 +31,7 @@ vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) test('session_list includes device_udid and ios_simulator_device_set for iOS sessions', async () => { const sessionStore = makeSessionStore(); - sessionStore.set( + sessionStore.publish( 'ios-scoped', makeSession('ios-scoped', { platform: 'apple', @@ -42,7 +42,7 @@ test('session_list includes device_udid and ios_simulator_device_set for iOS ses simulatorSetPath: '/tmp/tenant-a/simulators', }), ); - sessionStore.set( + sessionStore.publish( 'android-1', makeSession('android-1', { platform: 'android', @@ -52,7 +52,7 @@ test('session_list includes device_udid and ios_simulator_device_set for iOS ses booted: true, }), ); - sessionStore.set( + sessionStore.publish( 'macos-1', makeSession('macos-1', { platform: 'apple', @@ -220,7 +220,7 @@ test('test cleans up suite-owned sessions after each executed script', async () logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), sessionStore, invoke: async (req) => { - sessionStore.set( + sessionStore.publish( req.session, makeSession(req.session, { platform: 'android', diff --git a/src/daemon/__tests__/replay-suite/session-test-suite-command-video.test.ts b/src/daemon/__tests__/replay-suite/session-test-suite-command-video.test.ts index da008d36ab..2b68cd939a 100644 --- a/src/daemon/__tests__/replay-suite/session-test-suite-command-video.test.ts +++ b/src/daemon/__tests__/replay-suite/session-test-suite-command-video.test.ts @@ -136,7 +136,6 @@ function startMockRecording(params: { metadata: { phase: 'active' }, }), }; - sessionStore.set(req.session, session); } return { ok: true, data: { recording: 'started', outPath: state.recordingPath } }; } @@ -152,7 +151,6 @@ async function stopMockRecording(params: { if (session) { await session.screenRecording?.handle.finish(); session.screenRecording = undefined; - sessionStore.set(req.session, session); state.liveSlotCleared = sessionStore.get(req.session)?.screenRecording === undefined; } fs.writeFileSync(state.recordingPath, 'video'); @@ -290,7 +288,7 @@ test('test finalizes replay video exactly once when cancellation arrives after s nestedRequests.push(nestedReq); if (nestedReq.command === 'open') { const provisionalSession = makeIosSession(nestedReq.session); - sessionStore.set(nestedReq.session, provisionalSession); + sessionStore.publish(nestedReq.session, provisionalSession); const hookResponse = await nestedReq.internal?.openLifecycle?.beforeDispatch?.(); if (hookResponse && !hookResponse.ok) return hookResponse; events.push('open:dispatch'); diff --git a/src/daemon/__tests__/replay-suite/session-test-suite.test.ts b/src/daemon/__tests__/replay-suite/session-test-suite.test.ts index 13438a94ef..443cda9c61 100644 --- a/src/daemon/__tests__/replay-suite/session-test-suite.test.ts +++ b/src/daemon/__tests__/replay-suite/session-test-suite.test.ts @@ -354,7 +354,7 @@ test('test stops before retrying when a rejected close leaves the prior macOS se }, invoke: async (req) => { replayAttempts += 1; - sessionStore.set(req.session, makeMacOsSession(req.session)); + sessionStore.publish(req.session, makeMacOsSession(req.session)); return { ok: false, error: { code: 'COMMAND_FAILED', message: 'open "System Settings" failed' }, @@ -497,7 +497,7 @@ test('test aggregates snapshot diagnostics from replay session samples', async ( backend: 'android', platform: 'android', }); - sessionStore.set(req.session, session); + sessionStore.publish(req.session, session); return { ok: true, data: { replayed: 1, healed: 0 } }; }, }); @@ -554,7 +554,7 @@ test('test aggregates snapshot diagnostics from failed replay session samples', backend: 'android', platform: 'android', }); - sessionStore.set(req.session, session); + sessionStore.publish(req.session, session); return { ok: false, error: { code: 'COMMAND_FAILED', message: 'open failed' }, diff --git a/src/daemon/__tests__/replay-target/session-replay-dispatch-selector-miss.test.ts b/src/daemon/__tests__/replay-target/session-replay-dispatch-selector-miss.test.ts index fa550aaa33..2fee6c82cc 100644 --- a/src/daemon/__tests__/replay-target/session-replay-dispatch-selector-miss.test.ts +++ b/src/daemon/__tests__/replay-target/session-replay-dispatch-selector-miss.test.ts @@ -64,7 +64,10 @@ beforeEach(() => { function setupSession(root: string): { sessionStore: SessionStore; sessionName: string } { const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); return { sessionStore, sessionName }; } diff --git a/src/daemon/__tests__/replay-target/session-replay-scenario.fixtures.ts b/src/daemon/__tests__/replay-target/session-replay-scenario.fixtures.ts index f129fc90fb..91d4f070ab 100644 --- a/src/daemon/__tests__/replay-target/session-replay-scenario.fixtures.ts +++ b/src/daemon/__tests__/replay-target/session-replay-scenario.fixtures.ts @@ -22,7 +22,7 @@ function iosReplayScene(prefix: string): ReplaySessionScene { const root = mkdtempForTestSync(prefix); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosAppSession(sessionName)); + sessionStore.publish(sessionName, makeIosAppSession(sessionName)); return { root, sessionStore, sessionName, logPath: path.join(root, 'daemon.log') }; } diff --git a/src/daemon/__tests__/replay-target/session-replay-selector-routes.test.ts b/src/daemon/__tests__/replay-target/session-replay-selector-routes.test.ts index 1290acc40b..a30e64b37c 100644 --- a/src/daemon/__tests__/replay-target/session-replay-selector-routes.test.ts +++ b/src/daemon/__tests__/replay-target/session-replay-selector-routes.test.ts @@ -39,7 +39,7 @@ test('replay executes selector reads before reporting a covered-target divergenc const root = mkdtempForTestSync('agent-device-replay-selector-routes-'); const sessionName = 'default'; const sessionStore = new SessionStore(path.join(root, 'sessions')); - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, [ 'open "Demo"', String.raw`get text "id=\"field-name\""`, diff --git a/src/daemon/__tests__/request-dispatch-disclosure.test.ts b/src/daemon/__tests__/request-dispatch-disclosure.test.ts index 9ecc449a4b..24a5233d3a 100644 --- a/src/daemon/__tests__/request-dispatch-disclosure.test.ts +++ b/src/daemon/__tests__/request-dispatch-disclosure.test.ts @@ -122,7 +122,7 @@ async function route( androidObservation: AndroidObservationAdapter = clearAndroidObservationFixture, ) { const sessionStore = makeSessionStore('agent-device-dispatch-route-'); - sessionStore.set(SESSION, session); + sessionStore.publish(SESSION, session); const handler = createRequestHandler({ logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', diff --git a/src/daemon/__tests__/request-execution-scope-lease-expiry.test.ts b/src/daemon/__tests__/request-execution-scope-lease-expiry.test.ts new file mode 100644 index 0000000000..744997ecfe --- /dev/null +++ b/src/daemon/__tests__/request-execution-scope-lease-expiry.test.ts @@ -0,0 +1,132 @@ +import { expect, test, vi } from 'vitest'; +import { makeSession } from '../../__tests__/test-utils/session-factories.ts'; +import { LINUX_DEVICE } from '../../__tests__/test-utils/device-fixtures.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { LeaseRegistry } from '../lease-registry.ts'; +import { createRequestExecutionScope } from '../request-execution-scope.ts'; +import type { DaemonRequest } from '../daemon-request.ts'; + +function makeRequest(overrides: Partial): DaemonRequest { + return { + token: 't', + session: 'default', + command: 'snapshot', + positionals: [], + flags: {}, + ...overrides, + }; +} + +test('expired leases remove owned sessions before the next command and free capacity', async () => { + let now = 1_000; + const sessionStore = makeSessionStore('agent-device-request-scope-'); + const leaseRegistry = new LeaseRegistry({ + maxActiveSimulatorLeases: 1, + defaultLeaseTtlMs: 10, + minLeaseTtlMs: 1, + now: () => now, + }); + const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' }); + sessionStore.publish( + 'default', + makeSession('default', { + device: LINUX_DEVICE, + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + leaseProvider: 'proxy', + deviceKey: 'ios:SIM-001', + expiresAt: lease.expiresAt, + }, + }), + ); + now = 1_011; + + const scope = await createRequestExecutionScope({ + req: makeRequest({ command: 'snapshot' }), + sessionStore, + leaseRegistry, + }); + await scope.runLocked(async () => 'ran'); + + expect(sessionStore.get('default')).toBeUndefined(); + const nextLease = leaseRegistry.allocateLease({ tenantId: 'tenant-b', runId: 'run-2' }); + expect(nextLease.tenantId).toBe('tenant-b'); +}); + +test.each(['rebuild', 'retire'] as const)( + 'scoped lease expiry preserves a public-name session and a %s during held teardown', + async (change) => { + let now = 1_000; + const store = makeSessionStore('request-scope-lease-lifetime-'); + const leases = new LeaseRegistry({ defaultLeaseTtlMs: 10, minLeaseTtlMs: 1, now: () => now }); + const lease = leases.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' }); + const address = 'cwd:ownership:default'; + const original = store.publish( + address, + makeSession('default', { + device: LINUX_DEVICE, + sessionScope: { kind: 'cwd', id: 'ownership' }, + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + }, + }), + ); + const decoy = store.publish( + 'default', + makeSession('default', { + device: { ...LINUX_DEVICE, id: 'public-name-decoy' }, + appName: 'decoy', + }), + ); + let enter!: () => void; + let resume!: () => void; + const entered = new Promise((resolve) => { + enter = resolve; + }); + const release = new Promise((resolve) => { + resume = resolve; + }); + const stopSnapshotHelper = vi.fn(async () => { + enter(); + await release; + }); + now = 1_011; + const scope = await createRequestExecutionScope({ + req: makeRequest({ session: address, flags: { session: address } }), + sessionStore: store, + leaseRegistry: leases, + platformResourceCleanup: { + stopSnapshotHelper, + closeManagedBrowser: async () => {}, + cleanupSessionlessExecutionHost: async () => {}, + retainExecutionHostAfterClose: () => false, + }, + }); + expect(scope.sessionName).toBe(address); + const running = scope.runLocked(async () => 'ran'); + await entered; + let successor: ReturnType | undefined; + if (change === 'retire') { + store.retire(original); + successor = store.publish( + address, + makeSession('default', { + device: { ...LINUX_DEVICE, id: 'successor' }, + appName: 'successor', + }), + ); + } else store.update(original, { appName: 'latest' }); + resume(); + await expect(running).resolves.toBe('ran'); + expect(stopSnapshotHelper).toHaveBeenCalledExactlyOnceWith(original.session.device); + expect(store.requireCurrent(decoy)).toBe(decoy.session); + if (successor) expect(store.requireCurrent(successor)).toBe(successor.session); + else expect(store.lookup(address)).toBeUndefined(); + }, +); diff --git a/src/daemon/__tests__/request-execution-scope-open-device-wait.test.ts b/src/daemon/__tests__/request-execution-scope-open-device-wait.test.ts index c623e1f707..b533eedfd2 100644 --- a/src/daemon/__tests__/request-execution-scope-open-device-wait.test.ts +++ b/src/daemon/__tests__/request-execution-scope-open-device-wait.test.ts @@ -91,6 +91,7 @@ function openOnUncontendedDevice( name: string, sessionStore: ReturnType, order: string[], + releaseAfterMs?: number, ): Promise { return scope.runLocked(async () => { const currentOwner = sessionStore.findByDevice(CONTESTED_DEVICE.id); @@ -98,7 +99,8 @@ function openOnUncontendedDevice( return `refused:${currentOwner.address}`; } - sessionStore.set(name, sessionOnDevice(name)); + const ref = sessionStore.publish(name, sessionOnDevice(name)); + if (releaseAfterMs !== undefined) setTimeout(() => sessionStore.retire(ref), releaseAfterMs); order.push(name); return `opened:${name}`; }); @@ -107,11 +109,10 @@ function openOnUncontendedDevice( test('an open that lost the race to a free device re-waits and opens rather than refusing', async () => { const sessionStore = makeSessionStore('agent-device-open-wait-race-'); const leaseRegistry = new LeaseRegistry(); - sessionStore.set('holder', sessionOnDevice('holder')); + const holder = sessionStore.publish('holder', sessionOnDevice('holder')); // The holder lets go shortly after; the first open to bind the device lets go again after that, // the way any session closed by its owner would. - setTimeout(() => sessionStore.delete('holder'), 50); - setTimeout(() => sessionStore.delete('first-opener'), 400); + setTimeout(() => sessionStore.retire(holder), 50); const first = await createRequestExecutionScope({ req: openRequest('first-opener'), @@ -128,7 +129,7 @@ test('an open that lost the race to a free device re-waits and opens rather than const startedAtMs = Date.now(); // Each open binds a device to its own session under its own device lock, which is what a real // open does. The second can therefore only get in once the first releases it. - const firstOpened = openOnUncontendedDevice(first, 'first-opener', sessionStore, order); + const firstOpened = openOnUncontendedDevice(first, 'first-opener', sessionStore, order, 350); const secondOpened = openOnUncontendedDevice(second, 'second-opener', sessionStore, order); await expect(firstOpened).resolves.toBe('opened:first-opener'); @@ -147,7 +148,7 @@ test('an open that lost the race to a free device re-waits and opens rather than test('a close that frees the device mid-wait gets through while the open is waiting', async () => { const sessionStore = makeSessionStore('agent-device-open-wait-close-'); const leaseRegistry = new LeaseRegistry(); - sessionStore.set('holder', sessionOnDevice('holder')); + const holder = sessionStore.publish('holder', sessionOnDevice('holder')); const order: string[] = []; const opened = createRequestExecutionScope({ @@ -157,7 +158,7 @@ test('a close that frees the device mid-wait gets through while the open is wait }).then((scope) => scope.runLocked(async () => { order.push('open-bound'); - sessionStore.set('waiter', sessionOnDevice('waiter')); + sessionStore.publish('waiter', sessionOnDevice('waiter')); return 'opened'; }), ); @@ -172,7 +173,7 @@ test('a close that frees the device mid-wait gets through while the open is wait await expect( closer.runLocked(async () => { order.push('close-freed-the-device'); - sessionStore.delete('holder'); + sessionStore.retire(holder); return 'closed'; }), ).resolves.toBe('closed'); diff --git a/src/daemon/__tests__/request-execution-scope.test.ts b/src/daemon/__tests__/request-execution-scope.test.ts index 5da8da19d9..8c6fe6c221 100644 --- a/src/daemon/__tests__/request-execution-scope.test.ts +++ b/src/daemon/__tests__/request-execution-scope.test.ts @@ -9,9 +9,7 @@ import { import { makeAndroidSession, makeIosSession, - makeSession, } from '../../__tests__/test-utils/session-factories.ts'; -import { LINUX_DEVICE } from '../../__tests__/test-utils/device-fixtures.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { clearRequestCanceled, markRequestCanceled } from '@agent-device/host-kit/request'; @@ -196,7 +194,7 @@ test('leased session admission uses stored lease metadata and heartbeats', async clientId: 'client-a', deviceKey: 'ios:sim-1', }); - sessionStore.set( + sessionStore.publish( 'default', makeIosSession('default', { lease: { @@ -239,7 +237,7 @@ test('leased session heartbeat is serialized with the request execution lock', a clientId: 'client-a', deviceKey: 'ios:sim-1', }); - sessionStore.set( + sessionStore.publish( 'default', makeIosSession('default', { lease: { @@ -295,7 +293,7 @@ test('leased session heartbeat is serialized with the request execution lock', a test('a later external command cannot interleave with replay observation finalization', async () => { const sessionStore = makeSessionStore('agent-device-request-scope-'); - sessionStore.set('default', makeIosSession('default')); + sessionStore.publish('default', makeIosSession('default')); const leaseRegistry = new LeaseRegistry(); const replay = await createRequestExecutionScope({ req: makeRequest({ command: 'replay' }), @@ -352,7 +350,7 @@ test('a fresh replay keeps its session lock after a nested open binds the device const replayRun = replay.runLocked( async () => await new Promise((resolve) => { - sessionStore.set('default', makeIosSession('default')); + sessionStore.publish('default', makeIosSession('default')); finishReplay = resolve; sessionOpened(); }), @@ -381,7 +379,7 @@ test('leased session rejects mismatched lease id before dispatch', async () => { const sessionStore = makeSessionStore('agent-device-request-scope-'); const leaseRegistry = new LeaseRegistry(); const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' }); - sessionStore.set( + sessionStore.publish( 'default', makeIosSession('default', { lease: { @@ -412,7 +410,7 @@ test.each([ const sessionStore = makeSessionStore('agent-device-request-scope-'); const leaseRegistry = new LeaseRegistry(); const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' }); - sessionStore.set( + sessionStore.publish( 'default', makeIosSession('default', { lease: { @@ -440,7 +438,7 @@ test.each([ test('local unleased session admission still succeeds', async () => { const sessionStore = makeSessionStore('agent-device-request-scope-'); - sessionStore.set('default', makeIosSession('default')); + sessionStore.publish('default', makeIosSession('default')); const scope = await createRequestExecutionScope({ req: makeRequest({ command: 'snapshot' }), @@ -453,7 +451,7 @@ test('local unleased session admission still succeeds', async () => { test('local unleased session ignores stale lease id without tenant scope', async () => { const sessionStore = makeSessionStore('agent-device-request-scope-'); - sessionStore.set('default', makeIosSession('default')); + sessionStore.publish('default', makeIosSession('default')); const scope = await createRequestExecutionScope({ req: makeRequest({ command: 'snapshot', @@ -475,7 +473,7 @@ test('provider lease admission succeeds without a device key', async () => { leaseBackend: 'android-instance', leaseProvider: 'limrun', }); - sessionStore.set( + sessionStore.publish( 'default', makeAndroidSession('default', { lease: { @@ -497,45 +495,6 @@ test('provider lease admission succeeds without a device key', async () => { expect(scope.sessionName).toBe('default'); }); -test('expired leases remove owned sessions before the next command and free capacity', async () => { - let now = 1_000; - const sessionStore = makeSessionStore('agent-device-request-scope-'); - const leaseRegistry = new LeaseRegistry({ - maxActiveSimulatorLeases: 1, - defaultLeaseTtlMs: 10, - minLeaseTtlMs: 1, - now: () => now, - }); - const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' }); - sessionStore.set( - 'default', - makeSession('default', { - device: LINUX_DEVICE, - lease: { - leaseId: lease.leaseId, - tenantId: lease.tenantId, - runId: lease.runId, - leaseBackend: lease.backend, - leaseProvider: 'proxy', - deviceKey: 'ios:SIM-001', - expiresAt: lease.expiresAt, - }, - }), - ); - now = 1_011; - - const scope = await createRequestExecutionScope({ - req: makeRequest({ command: 'snapshot' }), - sessionStore, - leaseRegistry, - }); - await scope.runLocked(async () => 'ran'); - - expect(sessionStore.get('default')).toBeUndefined(); - const nextLease = leaseRegistry.allocateLease({ tenantId: 'tenant-b', runId: 'run-2' }); - expect(nextLease.tenantId).toBe('tenant-b'); -}); - // A lease renewed only at admission lets one command slower than its inactivity TTL // expire the lease paying for the device it is using, and expiry then tears the // provider session down under the client still waiting for that same command. Found @@ -550,7 +509,7 @@ test('an admitted request that outlives the lease TTL keeps its lease and sessio now: () => now, }); const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' }); - sessionStore.set( + sessionStore.publish( 'default', makeIosSession('default', { lease: { @@ -591,7 +550,7 @@ test('expired leased session cleanup waits for the request execution lock', asyn now: () => now, }); const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' }); - sessionStore.set( + sessionStore.publish( 'default', makeIosSession('default', { lease: { @@ -682,7 +641,7 @@ test('tenant lease rejection flushes diagnostics into the effective session requ test('prepareLockedRequestScope preserves existing-session selector validation', async () => { const sessionStore = makeSessionStore('agent-device-request-scope-'); - sessionStore.set('default', makeAndroidSession('default')); + sessionStore.publish('default', makeAndroidSession('default')); const scope = await createRequestExecutionScope({ req: makeRequest({ command: 'snapshot', @@ -713,7 +672,7 @@ test('prepareLockedRequestScope blocks commands for invalidated recordings befor showTouches: true, invalidatedReason: 'iOS runner session restarted during recording', }); - sessionStore.set('default', session); + sessionStore.publish('default', session); const scope = await createRequestExecutionScope({ req: makeRequest({ command: 'snapshot' }), sessionStore, @@ -741,7 +700,7 @@ test('prepareLockedRequestScope blocks commands for invalidated recordings befor test('prepareLockedRequestScope passes the session runner log path into handler context', async () => { const sessionStore = makeSessionStore('agent-device-request-scope-'); - sessionStore.set('default', makeIosSession('default')); + sessionStore.publish('default', makeIosSession('default')); const scope = await createRequestExecutionScope({ req: makeRequest({ command: 'snapshot' }), sessionStore, @@ -764,7 +723,7 @@ test('prepareLockedRequestScope passes the session runner log path into handler test('prepareLockedRequestScope streams ordinary diagnostics into the active trace', async () => { const sessionStore = makeSessionStore('agent-device-request-scope-'); const tracePath = path.join(TEST_ROOT, 'active-session.trace'); - sessionStore.set( + sessionStore.publish( 'default', makeIosSession('default', { trace: { outPath: tracePath, startedAt: Date.now() }, @@ -808,7 +767,7 @@ test('runLocked rejects a canceled request before executing work', async () => { test('runLocked rejects a request canceled while waiting for its execution lock', async () => { const requestId = 'request-scope-canceled-after-lock'; const sessionStore = makeSessionStore('agent-device-request-scope-'); - sessionStore.set('default', makeIosSession('default')); + sessionStore.publish('default', makeIosSession('default')); const leaseRegistry = new LeaseRegistry(); const first = await createRequestExecutionScope({ req: makeRequest({ command: 'click' }), @@ -908,7 +867,7 @@ test('router: deferred tenant connect still refuses an app-target close before d // run-scoped), so a missing lease field alone is not proof of ownership. test('router: an existing lease-less session under tenant isolation still refuses close', async () => { const sessionStore = makeSessionStore('agent-device-request-scope-'); - sessionStore.set('tenant-a:default', makeIosSession('tenant-a:default')); + sessionStore.publish('tenant-a:default', makeIosSession('tenant-a:default')); const leaseRegistry = new LeaseRegistry(); const scope = await createRequestExecutionScope({ @@ -947,11 +906,11 @@ async function createScopeAcrossTwoImplicitWorkspaceSessions(command: string) { const scope = resolveSessionScope({ ...makeRequest({ command }), meta: { cwd: root } }); if (scope.kind !== 'cwd') throw new Error('expected a cwd session scope'); const sessionStore = makeSessionStore('agent-device-request-scope-ambiguity-'); - sessionStore.set( + sessionStore.publish( `cwd:${scope.id}:ios`, makeIosSession('default', { sessionScope: { kind: 'cwd', id: scope.id } }), ); - sessionStore.set( + sessionStore.publish( `cwd:${scope.id}:android`, makeAndroidSession('default', { sessionScope: { kind: 'cwd', id: scope.id } }), ); diff --git a/src/daemon/__tests__/request-handler-chain-provider-scope.test.ts b/src/daemon/__tests__/request-handler-chain-provider-scope.test.ts index cbf61f271b..52cbdf6e35 100644 --- a/src/daemon/__tests__/request-handler-chain-provider-scope.test.ts +++ b/src/daemon/__tests__/request-handler-chain-provider-scope.test.ts @@ -37,7 +37,7 @@ function makeRequest(command: string, sessionName: string): DaemonRequest { function baseChainParams(sessionName: string) { const sessionStore = makeSessionStore('agent-device-provider-scope-'); - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); return { req: makeRequest(INTERNAL_COMMANDS.runtime, sessionName), sessionName, diff --git a/src/daemon/__tests__/request-handler-chain.test.ts b/src/daemon/__tests__/request-handler-chain.test.ts index c6ce269e2d..ee766f96b5 100644 --- a/src/daemon/__tests__/request-handler-chain.test.ts +++ b/src/daemon/__tests__/request-handler-chain.test.ts @@ -40,7 +40,7 @@ function makeRequest(command: string, positionals: string[] = []): DaemonRequest function makeChainParams(req: DaemonRequest) { const sessionStore = makeSessionStore('agent-device-request-chain-'); - sessionStore.set('chain-test', makeIosSession('chain-test')); + sessionStore.publish('chain-test', makeIosSession('chain-test')); return { req, sessionName: 'chain-test', @@ -244,7 +244,7 @@ test('swipe rejects repetition inputs that can monopolize the request', async () test('duration-less public coordinate swipe retains Linux drag behavior', async () => { const sessionStore = makeSessionStore('agent-device-linux-swipe-'); - sessionStore.set('linux-swipe', makeSession('linux-swipe', { device: LINUX_DEVICE })); + sessionStore.publish('linux-swipe', makeSession('linux-swipe', { device: LINUX_DEVICE })); const drags: number[][] = []; let captureCount = 0; const provider = await createLocalLinuxToolProvider({ diff --git a/src/daemon/__tests__/request-lock-identity-policy.test.ts b/src/daemon/__tests__/request-lock-identity-policy.test.ts index 803f608f1c..e84906715f 100644 --- a/src/daemon/__tests__/request-lock-identity-policy.test.ts +++ b/src/daemon/__tests__/request-lock-identity-policy.test.ts @@ -1,3 +1,4 @@ +import { makeStoredSessionRef } from '../../__tests__/test-utils/store-factory.ts'; import { test } from 'vitest'; import assert from 'node:assert/strict'; import { AppError } from '@agent-device/kernel/errors'; @@ -175,7 +176,7 @@ const ROWS: Row[] = [ /** Every row's session is explicitly named, so it is stored under — and addressed by — its name. */ function ref(session: SessionState | undefined): SessionRef | undefined { - return session ? { address: session.name, session } : undefined; + return session ? makeStoredSessionRef(session) : undefined; } for (const row of ROWS) { diff --git a/src/daemon/__tests__/request-lock-policy.test.ts b/src/daemon/__tests__/request-lock-policy.test.ts index a56b5fdb6d..2db8ab0be1 100644 --- a/src/daemon/__tests__/request-lock-policy.test.ts +++ b/src/daemon/__tests__/request-lock-policy.test.ts @@ -1,3 +1,4 @@ +import { makeStoredSessionRef } from '../../__tests__/test-utils/store-factory.ts'; import { test } from 'vitest'; import assert from 'node:assert/strict'; import { applyRequestLockPolicy } from '../request-lock-policy.ts'; @@ -34,7 +35,7 @@ const ANDROID_SESSION: SessionState = { /** Both fixtures are explicitly named, so each is stored under — and addressed by — its name. */ function ref(session: SessionState): SessionRef { - return { address: session.name, session }; + return makeStoredSessionRef(session); } test('allows compatible fresh-session selectors under request lock policy', () => { 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-android-modal.test.ts b/src/daemon/__tests__/request-router-android-modal.test.ts index 4cd127c611..0320d46bc9 100644 --- a/src/daemon/__tests__/request-router-android-modal.test.ts +++ b/src/daemon/__tests__/request-router-android-modal.test.ts @@ -151,7 +151,7 @@ test('generic Android gesture commands dismiss blocking system dialogs during re gestureRuntimeSpies.scrollDirection.mockClear(); const sessionStore = makeSessionStore('agent-device-router-android-modal-'); - sessionStore.set('default', makeAndroidSession('default')); + sessionStore.publish('default', makeAndroidSession('default')); const { openAndroidApp } = await import('@agent-device/platform-android/mechanics'); @@ -202,7 +202,7 @@ test('generic Android gesture commands continue when recording dialog inspection }); const sessionStore = makeSessionStore('agent-device-router-android-modal-'); - sessionStore.set('default', makeAndroidSession('default')); + sessionStore.publish('default', makeAndroidSession('default')); const { openAndroidApp } = await import('@agent-device/platform-android/mechanics'); vi.mocked(openAndroidApp).mockClear(); @@ -254,7 +254,7 @@ test('generic Android gesture commands skip local dialog recovery for provider d const sessionStore = makeSessionStore('agent-device-router-android-modal-provider-'); const session = makeAndroidSession('default'); - sessionStore.set('default', session); + sessionStore.publish('default', session); const runtime: ProviderDeviceRuntime = { provider: 'webdriver-fake', diff --git a/src/daemon/__tests__/request-router-android-perf.test.ts b/src/daemon/__tests__/request-router-android-perf.test.ts index 4f9ec43836..21608bbb8e 100644 --- a/src/daemon/__tests__/request-router-android-perf.test.ts +++ b/src/daemon/__tests__/request-router-android-perf.test.ts @@ -15,7 +15,7 @@ import { function makeAndroidSessionStore(name: string): SessionStore { const sessionStore = new SessionStore(`/tmp/${name}`); - sessionStore.set('default', { + sessionStore.publish('default', { name: 'default', createdAt: Date.now(), device: { diff --git a/src/daemon/__tests__/request-router-android-snapshot-helper.test.ts b/src/daemon/__tests__/request-router-android-snapshot-helper.test.ts index e4f936007e..3bb4b23ce4 100644 --- a/src/daemon/__tests__/request-router-android-snapshot-helper.test.ts +++ b/src/daemon/__tests__/request-router-android-snapshot-helper.test.ts @@ -14,7 +14,7 @@ import { function makeAndroidSessionStore(name: string): SessionStore { const sessionStore = new SessionStore(`/tmp/${name}`); - sessionStore.set('default', { + sessionStore.publish('default', { name: 'default', createdAt: Date.now(), device: { diff --git a/src/daemon/__tests__/request-router-artifacts-web.test.ts b/src/daemon/__tests__/request-router-artifacts-web.test.ts index 14e74b3dc2..0923d10c49 100644 --- a/src/daemon/__tests__/request-router-artifacts-web.test.ts +++ b/src/daemon/__tests__/request-router-artifacts-web.test.ts @@ -19,7 +19,7 @@ import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; // device is web, the same round trip `request-router-events.test.ts` already proves for `events`. test('artifacts lists a daemon-tracked artifact produced during a web session', async () => { const sessionStore = makeSessionStore('agent-device-router-artifacts-web-'); - sessionStore.set('web-session', makeSession('web-session', { device: WEB_DESKTOP_DEVICE })); + sessionStore.publish('web-session', makeSession('web-session', { device: WEB_DESKTOP_DEVICE })); const artifactDir = mkdtempForTestSync('agent-device-router-artifacts-web-file-'); const artifactPath = path.join(artifactDir, 'web-smoke.png'); fs.writeFileSync(artifactPath, 'fixture-bytes'); diff --git a/src/daemon/__tests__/request-router-cost.test.ts b/src/daemon/__tests__/request-router-cost.test.ts index 8eeb772cf2..df934bbb85 100644 --- a/src/daemon/__tests__/request-router-cost.test.ts +++ b/src/daemon/__tests__/request-router-cost.test.ts @@ -85,7 +85,7 @@ beforeEach(() => { test('(a) flag-off identity: meta.includeCost absent === no meta at all, byte-identical and no cost', async () => { const { sessionStore, handler } = makeHandler(); - sessionStore.set('cost-session', makeIosSession('cost-session')); + sessionStore.publish('cost-session', makeIosSession('cost-session')); const respNoMeta = await handler(baseRequest()); const respMetaWithoutCost = await handler(baseRequest({ meta: {} })); @@ -107,7 +107,7 @@ test('(a) flag-off identity: meta.includeCost absent === no meta at all, byte-id test('(b) flag-on additive-only: cost block is the ONLY delta vs flag-off', async () => { const { sessionStore, handler } = makeHandler(); - sessionStore.set('cost-session', makeIosSession('cost-session')); + sessionStore.publish('cost-session', makeIosSession('cost-session')); const respFlagOff = await handler(baseRequest()); const respFlagOn = await handler(baseRequest({ meta: { includeCost: true } })); @@ -134,7 +134,7 @@ test('(b) flag-on additive-only: cost block is the ONLY delta vs flag-off', asyn test('(c) runnerRoundTrips counts real iOS-runner round-trip diagnostics in scope', async () => { const { sessionStore, handler } = makeHandler(); - sessionStore.set('cost-session', makeIosSession('cost-session')); + sessionStore.publish('cost-session', makeIosSession('cost-session')); // The bound operation runs inside the request's diagnostics scope, so emitting here is // equivalent to the runner-session emitting these phases per round-trip. @@ -157,7 +157,7 @@ test('(c) runnerRoundTrips counts real iOS-runner round-trip diagnostics in scop test('(c2) nodeCount reports the node-tree size whenever data carries a nodes array, additive-only', async () => { const { sessionStore, handler } = makeHandler(); - sessionStore.set('cost-session', makeIosSession('cost-session')); + sessionStore.publish('cost-session', makeIosSession('cost-session')); // The nodeCount read is command-agnostic: it triggers on any response.data that // carries a `nodes` array (in production only the snapshot node-tree commands @@ -191,7 +191,7 @@ test('(c2) nodeCount reports the node-tree size whenever data carries a nodes ar test('(d) error path: a failing request with includeCost:true produces NO cost', async () => { const { sessionStore, handler } = makeHandler(); - sessionStore.set('cost-session', makeIosSession('cost-session')); + sessionStore.publish('cost-session', makeIosSession('cost-session')); // Conflicting explicit selector under a reject lock policy fails before the bound execution. const failingRequest = baseRequest({ diff --git a/src/daemon/__tests__/request-router-events.test.ts b/src/daemon/__tests__/request-router-events.test.ts index 6db32722a2..1c4e7a8d32 100644 --- a/src/daemon/__tests__/request-router-events.test.ts +++ b/src/daemon/__tests__/request-router-events.test.ts @@ -180,7 +180,7 @@ test('events flushes pending event writes before reading', async () => { // every other platform. test('events reads the daemon-owned session timeline for a web-backed session', async () => { const sessionStore = makeSessionStore('agent-device-router-events-web-'); - sessionStore.set('web-session', makeSession('web-session', { device: WEB_DESKTOP_DEVICE })); + sessionStore.publish('web-session', makeSession('web-session', { device: WEB_DESKTOP_DEVICE })); sessionStore.recordEvent('web-session', { kind: 'action.recorded', command: 'click', @@ -218,7 +218,7 @@ test('events reads the daemon-owned session timeline for a web-backed session', test('request timeline records thrown request failures after scope creation', async () => { const sessionStore = makeSessionStore('agent-device-router-events-throws-'); - sessionStore.set('events-session', makeIosSession('events-session')); + sessionStore.publish('events-session', makeIosSession('events-session')); const handler = createRequestHandler({ logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), 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-lock-policy.test.ts b/src/daemon/__tests__/request-router-lock-policy.test.ts index 45da103c58..d1debf25b5 100644 --- a/src/daemon/__tests__/request-router-lock-policy.test.ts +++ b/src/daemon/__tests__/request-router-lock-policy.test.ts @@ -122,7 +122,7 @@ function installGatedDispatch(): { test('direct daemon requests cannot bypass reject lock policy for existing sessions', async () => { const sessionStore = makeSessionStore('agent-device-router-lock-'); - sessionStore.set('qa-ios', makeIosSession('qa-ios')); + sessionStore.publish('qa-ios', makeIosSession('qa-ios')); const handler = createRequestHandler({ logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), @@ -455,7 +455,7 @@ test('fresh named sessions reject incompatible selector combinations before bind test('batch steps cannot bypass reject lock policy on nested direct requests', async () => { const sessionStore = makeSessionStore('agent-device-router-lock-'); - sessionStore.set('qa-ios', makeIosSession('qa-ios')); + sessionStore.publish('qa-ios', makeIosSession('qa-ios')); const handler = createRequestHandler({ logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), @@ -499,7 +499,7 @@ test('batch steps cannot bypass reject lock policy on nested direct requests', a test('direct daemon requests apply strip lock policy for existing sessions before dispatch', async () => { const sessionStore = makeSessionStore('agent-device-router-lock-'); - sessionStore.set('qa-ios', makeIosSession('qa-ios')); + sessionStore.publish('qa-ios', makeIosSession('qa-ios')); systemRuntimeSpies.appSwitcher.mockClear(); const handler = createRequestHandler({ @@ -538,7 +538,7 @@ test('strip lock policy still refuses a request naming a different device, befor // The wrong-device footgun: `strip` used to delete --udid and run the command against the bound // session's device instead. A request that names another device must fail, not silently retarget. const sessionStore = makeSessionStore('agent-device-router-lock-'); - sessionStore.set('qa-ios', makeIosSession('qa-ios')); + sessionStore.publish('qa-ios', makeIosSession('qa-ios')); let dispatchCalls = 0; legacyDispatchCapture.mockImplementation(async () => { dispatchCalls += 1; @@ -575,7 +575,7 @@ test('strip lock policy still refuses a request naming a different device, befor test('batch preserves tenant-scoped session names across nested requests', async () => { const sessionStore = makeSessionStore('agent-device-router-lock-'); - sessionStore.set('tenant-a:default', makeIosSession('tenant-a:default')); + sessionStore.publish('tenant-a:default', makeIosSession('tenant-a:default')); const leaseRegistry = new LeaseRegistry(); const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', diff --git a/src/daemon/__tests__/request-router-open.test.ts b/src/daemon/__tests__/request-router-open.test.ts index ec084f7c75..d182e86469 100644 --- a/src/daemon/__tests__/request-router-open.test.ts +++ b/src/daemon/__tests__/request-router-open.test.ts @@ -402,7 +402,7 @@ test('close releases the session lease', async () => { runId: 'run-1', clientId: 'client-a', }); - sessionStore.set('default', { + sessionStore.publish('default', { name: 'default', device: makeIosDevice('SIM-CLOSE'), createdAt: Date.now(), @@ -432,7 +432,7 @@ test('close releases the session lease', async () => { test('close fails synchronously when root composition omits platform resource cleanup', async () => { const sessionStore = makeSessionStore('agent-device-router-open-'); - sessionStore.set('default', { + sessionStore.publish('default', { name: 'default', device: makeIosDevice('SIM-CLOSE-MISSING-CLEANUP'), createdAt: Date.now(), @@ -475,7 +475,7 @@ test('close rejects a different client before cleanup', async () => { runId: 'run-1', clientId: 'client-a', }); - sessionStore.set('default', { + sessionStore.publish('default', { name: 'default', device: makeIosDevice('SIM-CLOSE-CLIENT'), createdAt: Date.now(), diff --git a/src/daemon/__tests__/request-router-record-runtime-lock.test.ts b/src/daemon/__tests__/request-router-record-runtime-lock.test.ts index 3355a9594d..942ee36e1c 100644 --- a/src/daemon/__tests__/request-router-record-runtime-lock.test.ts +++ b/src/daemon/__tests__/request-router-record-runtime-lock.test.ts @@ -75,7 +75,7 @@ test.each([ async (device) => { const sessionName = `record-${device.platform}-unsupported`; const sessionStore = makeSessionStore(`request-router-record-${device.platform}-unsupported-`); - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { name: sessionName, device, createdAt: 1, diff --git a/src/daemon/__tests__/request-router-recording-health.test.ts b/src/daemon/__tests__/request-router-recording-health.test.ts index 1b6be6aa8f..b46d1930f9 100644 --- a/src/daemon/__tests__/request-router-recording-health.test.ts +++ b/src/daemon/__tests__/request-router-recording-health.test.ts @@ -53,7 +53,7 @@ test('router blocks non-record commands when recording was invalidated', async ( startedAt: Date.now() - 1_000, invalidatedReason: 'iOS runner session restarted during recording', }); - sessionStore.set('default', session); + sessionStore.publish('default', session); const handler = createRequestHandler({ logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), @@ -104,7 +104,7 @@ test('router allows canonical iOS simulator gestures during overlay recording af startedAt: Date.now() - 1_000, runnerSessionId: 'runner-before', }); - sessionStore.set('default', session); + sessionStore.publish('default', session); mockObserveRunnerSession.mockResolvedValue({ alive: true, sessionId: 'runner-after', diff --git a/src/daemon/__tests__/request-router-repair-expired.test.ts b/src/daemon/__tests__/request-router-repair-expired.test.ts index 0e9947be33..f34f4cee8f 100644 --- a/src/daemon/__tests__/request-router-repair-expired.test.ts +++ b/src/daemon/__tests__/request-router-repair-expired.test.ts @@ -62,7 +62,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 +91,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 +112,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 +150,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', diff --git a/src/daemon/__tests__/request-router-replay-scope.test.ts b/src/daemon/__tests__/request-router-replay-scope.test.ts index 5413f916f7..9e1bf571a6 100644 --- a/src/daemon/__tests__/request-router-replay-scope.test.ts +++ b/src/daemon/__tests__/request-router-replay-scope.test.ts @@ -67,7 +67,7 @@ test('replay runs active-session actions inside the parent request provider scop const replayPath = path.join(root, 'flow.ad'); fs.writeFileSync(replayPath, 'app-switcher\nscroll down\n'); const sessionStore = makeSessionStore('agent-device-replay-scope-'); - sessionStore.set('default', makeIosSession('default', { appBundleId: 'com.example.app' })); + sessionStore.publish('default', makeIosSession('default', { appBundleId: 'com.example.app' })); const appleRunnerProvider = vi.fn(() => undefined); const handler = createRequestHandler({ @@ -105,7 +105,7 @@ test('replay routes session-changing actions through the full request path', asy const replayPath = path.join(root, 'flow.ad'); fs.writeFileSync(replayPath, 'runtime set --platform ios --metro-host localhost\napp-switcher\n'); const sessionStore = makeSessionStore('agent-device-replay-full-route-'); - sessionStore.set('default', makeIosSession('default', { appBundleId: 'com.example.app' })); + sessionStore.publish('default', makeIosSession('default', { appBundleId: 'com.example.app' })); const appleRunnerProvider = vi.fn(() => undefined); const handler = createRequestHandler({ @@ -231,7 +231,7 @@ test('fresh replay retains a dynamically selected device through finalization', }); await readinessEntered; - sessionStore.set('external', makeIosSession('external')); + sessionStore.publish('external', makeIosSession('external')); let externalRequestSettled = false; const externalResponse = handler({ token: 'test-token', diff --git a/src/daemon/__tests__/request-router-response-level.test.ts b/src/daemon/__tests__/request-router-response-level.test.ts index c7fcbffb55..82e89aa411 100644 --- a/src/daemon/__tests__/request-router-response-level.test.ts +++ b/src/daemon/__tests__/request-router-response-level.test.ts @@ -64,7 +64,7 @@ function makeIosSession(name: string): SessionState { function makeHandler() { const sessionStore = makeSessionStore('agent-device-router-level-'); - sessionStore.set('level-session', makeIosSession('level-session')); + sessionStore.publish('level-session', makeIosSession('level-session')); return { sessionStore, handler: createRequestHandler({ diff --git a/src/daemon/__tests__/request-router-screenshot.test.ts b/src/daemon/__tests__/request-router-screenshot.test.ts index 2e6a8229be..db017bd1cd 100644 --- a/src/daemon/__tests__/request-router-screenshot.test.ts +++ b/src/daemon/__tests__/request-router-screenshot.test.ts @@ -75,7 +75,7 @@ function screenshotRouter( options: ScreenshotRuntimeFixtureOptions = {}, ): ScreenshotRouter { const sessionStore = makeSessionStore('agent-device-router-screenshot-'); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const runtime = screenshotRuntimeFixture(options); const handler = createRequestHandler({ logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), @@ -202,8 +202,8 @@ test('click forwards macOS menubar session surface to the bound runtime', async test('router serializes concurrent commands for the same device across sessions', async () => { const sessionStore = makeSessionStore('agent-device-router-screenshot-'); - sessionStore.set('session-a', makeSession('session-a')); - sessionStore.set('session-b', makeSession('session-b')); + sessionStore.publish('session-a', makeSession('session-a')); + sessionStore.publish('session-b', makeSession('session-b')); const order: string[] = []; let active = 0; diff --git a/src/daemon/__tests__/request-router-typed-error.test.ts b/src/daemon/__tests__/request-router-typed-error.test.ts index bd54eb4d2b..6170f5cf40 100644 --- a/src/daemon/__tests__/request-router-typed-error.test.ts +++ b/src/daemon/__tests__/request-router-typed-error.test.ts @@ -100,7 +100,7 @@ test('fact-owned UNSUPPORTED_OPERATION errors do not fabricate legacy supportedO const { sessionStore, handler } = makeHandler(); // Linux refuses native perf from exact runtime facts. The retired capability matrix has no // trustworthy platform summary to graft onto that operation-level refusal. - sessionStore.set( + sessionStore.publish( 'typed-error', makeSession('typed-error', { ...TENANT_SESSION_DEFAULTS, @@ -125,7 +125,7 @@ test('fact-owned UNSUPPORTED_OPERATION errors do not fabricate legacy supportedO test('DEVICE_IN_USE errors are flagged retriable; supportedOn stays absent', async () => { const { sessionStore, handler } = makeHandler(); - sessionStore.set('typed-error', makeIosSession('typed-error')); + sessionStore.publish('typed-error', makeIosSession('typed-error')); // R56 put `app-switcher` on a bound operation, so the failure is raised where the device work // happens rather than by the retired dispatcher. systemRuntimeSpies.appSwitcher.mockRejectedValue(new AppError('DEVICE_IN_USE', 'device busy')); @@ -140,7 +140,7 @@ test('DEVICE_IN_USE errors are flagged retriable; supportedOn stays absent', asy test('deterministic errors (INVALID_ARGS) are returned with the default shape — no typed-error fields', async () => { const { sessionStore, handler } = makeHandler(); - sessionStore.set('typed-error', makeIosSession('typed-error')); + sessionStore.publish('typed-error', makeIosSession('typed-error')); // Conflicting explicit selector under a reject lock policy fails with INVALID_ARGS // before dispatch — a deterministic error. @@ -171,7 +171,7 @@ test('BLOCKER 2 (second follow-up): a repair-close platform-close failure surfac ...TENANT_SESSION_DEFAULTS, actions: [{ ts: 1, command: 'open', positionals: ['Demo'], flags: {} }], }); - sessionStore.set('typed-error', session); + sessionStore.publish('typed-error', session); // DEVICE_NOT_FOUND is not in `retriableForErrorCode`'s conservative allow // list — if the handler ever regressed to relying on that code-level @@ -216,7 +216,7 @@ test('#1391: an ordinary close-time script-save failure surfaces details.reason/ status: 'armed', target: { kind: 'explicit', path: targetPath, force: false }, }; - sessionStore.set('typed-error', session); + sessionStore.publish('typed-error', session); try { // Untargeted close: no positionals, so no platform close is dispatched diff --git a/src/daemon/__tests__/request-runtime-binding-router.test.ts b/src/daemon/__tests__/request-runtime-binding-router.test.ts index 19253287fa..ecb28d7b19 100644 --- a/src/daemon/__tests__/request-runtime-binding-router.test.ts +++ b/src/daemon/__tests__/request-runtime-binding-router.test.ts @@ -50,7 +50,7 @@ test('primary request failure survives a rejecting binding disposal', async () = function makeHandler(gateway: DeviceRuntimeGateway) { const sessionStore = makeSessionStore('request-runtime-binding-router-'); - sessionStore.set('session', { + sessionStore.publish('session', { name: 'session', device: { platform: 'android', id: 'emulator-5554', name: 'Pixel', kind: 'emulator' }, appBundleId: 'com.example.app', diff --git a/src/daemon/__tests__/request-save-script-transports.test.ts b/src/daemon/__tests__/request-save-script-transports.test.ts index 730a37d25a..1dd4273295 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'; /** @@ -67,7 +68,7 @@ function setup(): Harness { roots.push(root); const sessionStore = new SessionStore(path.join(root, 'sessions')); const session = makeIosSession(SESSION); - sessionStore.set(SESSION, session); + sessionStore.publish(SESSION, session); const handleRequest = createRequestHandler({ logPath: path.join(root, 'daemon.log'), token: TOKEN, @@ -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); @@ -286,7 +289,7 @@ test('an owner-armed session still records its target and publishes its script', // What `open`/`close --save-script` do once past the seam: arm the session, // then publish at teardown. Unchanged by the ingress rejection. - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'open', positionals: ['Example'], flags: { saveScript: target }, @@ -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-binding-conformance.ts b/src/daemon/__tests__/runtime-binding-conformance.ts index bfc125365c..43ed1454ff 100644 --- a/src/daemon/__tests__/runtime-binding-conformance.ts +++ b/src/daemon/__tests__/runtime-binding-conformance.ts @@ -148,7 +148,7 @@ export const conformedRuntimeBindings = { resolve: async (device, bindings) => { const sessionStore = makeSessionStore('agent-device-runtime-binding-conformance-'); const session = makeSession('diff-runtime', { device, appBundleId: 'com.example.app' }); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const response = await dispatchSnapshotDiffViaRuntime({ req: { command: 'diff', 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__/screen-recording-session-binding.test.ts b/src/daemon/__tests__/screen-recording-session-binding.test.ts new file mode 100644 index 0000000000..520b6b3452 --- /dev/null +++ b/src/daemon/__tests__/screen-recording-session-binding.test.ts @@ -0,0 +1,60 @@ +import { expect, test, vi } from 'vitest'; +import { createScreenRecordingLiveHandle } from '@agent-device/capture-kit'; +import { PendingTransferGuard } from '@agent-device/contracts/async-lifecycle'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { makeRecordingSession } from './session-teardown.fixtures.ts'; +import { bindRecordOnlyScreenRecording } from '../screen-recording-session-binding.ts'; +import { createScreenRecordingAdmissionLedger } from '@agent-device/capture-kit/screen-recording-admission-ledger'; +import { + adoptStartedScreenRecording, + screenRecordingDurableResource, +} from '@agent-device/capture-kit/screen-recording-session-resource'; + +test('shutdown refuses draft publication while retaining unconfirmed recording cleanup evidence', async () => { + const store = makeSessionStore(); + const session = makeRecordingSession({ + name: 'draft', + sessionStore: store, + finish: async () => ({ status: 'cleanup-pending', reason: 'cleanup-unconfirmed' }), + }); + const { handle: initial, envelope } = session.screenRecording!; + const cleanup = vi.fn( + async () => ({ status: 'cleanup-pending', reason: 'cleanup-unconfirmed' }) as const, + ); + const handle = createScreenRecordingLiveHandle(initial.inspect(), { + finish: async () => ({ status: 'cleanup-pending', reason: 'cleanup-unconfirmed' }), + forceCleanup: cleanup, + }); + const draft = bindRecordOnlyScreenRecording(store, 'draft', { + ...session, + screenRecording: undefined, + }); + store.closeAdmission(); + await expect( + adoptStartedScreenRecording({ + binding: draft.binding, + admissionLedger: createScreenRecordingAdmissionLedger(), + device: session.device, + owner: envelope.owner, + fence: envelope.fence, + pendingHandle: new PendingTransferGuard(handle), + envelope, + throwIfCanceled: () => {}, + }), + ).rejects.toMatchObject({ details: { reason: 'daemon_shutting_down' } }); + expect(cleanup).toHaveBeenCalledOnce(); + expect(store.lookup('draft')).toBeUndefined(); + const record = screenRecordingDurableResource.store.read( + screenRecordingDurableResource.store.resolvePath(draft.binding.sessionDir), + ); + expect(record).toMatchObject({ + status: 'decoded', + envelope: { + lifecycle: 'open', + descriptor: envelope.descriptor, + metadata: { phase: 'cleanup-pending' }, + }, + }); + if (record.status !== 'decoded') throw new Error('Expected recovery evidence'); + expect(record.envelope.metadata?.runtimeContractInvalid).toBeUndefined(); +}); diff --git a/src/daemon/__tests__/selector-capture-binding.test.ts b/src/daemon/__tests__/selector-capture-binding.test.ts index 1d94126750..d0046d80d7 100644 --- a/src/daemon/__tests__/selector-capture-binding.test.ts +++ b/src/daemon/__tests__/selector-capture-binding.test.ts @@ -134,7 +134,7 @@ test('a session without a tracked app selects the without-active-app plan', asyn test('the bound construction path admits and binds before it builds a runtime', async () => { const fixture = selectorCaptureFixture(); const sessionStore = makeSessionStore(); - sessionStore.set('bound', makeAndroidSession('bound')); + sessionStore.publish('bound', makeAndroidSession('bound')); const resolved = await createBoundSelectorRuntime( { @@ -158,7 +158,7 @@ test('the bound construction path refuses an unavailable operation without build capture: { available: false, reason: 'unsupported-platform-leaf' }, }); const sessionStore = makeSessionStore(); - sessionStore.set('bound', makeAndroidSession('bound')); + sessionStore.publish('bound', makeAndroidSession('bound')); const resolved = await createBoundSelectorRuntime( { diff --git a/src/daemon/__tests__/selector-capture-runtime.test.ts b/src/daemon/__tests__/selector-capture-runtime.test.ts index 9384dc7553..db9dd81471 100644 --- a/src/daemon/__tests__/selector-capture-runtime.test.ts +++ b/src/daemon/__tests__/selector-capture-runtime.test.ts @@ -38,7 +38,7 @@ test('selector capture cache is keyed by scoped presentation options', async () nodes: [{ ref: 'e1', index: 0, type: 'Button', label: 'A' }], }, }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); boundCapture.mockImplementation(async (input) => ({ backend: 'xctest', producer: 'apple-runner', @@ -52,8 +52,8 @@ test('selector capture cache is keyed by scoped presentation options', async () })); const runtime = createSelectorCaptureRuntime({ + ref: sessionStore.lookup(sessionName), device: session.device, - session, sessionStore, sessionName, capture: boundCapture, @@ -213,7 +213,7 @@ function proofRuntime(params: { params.sessionName, params.storedSnapshot ? { snapshot: params.storedSnapshot } : {}, ); - sessionStore.set(params.sessionName, session); + sessionStore.publish(params.sessionName, session); boundCapture.mockResolvedValue({ backend: 'xctest', producer: 'apple-runner', @@ -225,8 +225,8 @@ function proofRuntime(params: { const consumedSnapshot: { state?: SnapshotState } = {}; const captureProof: RequestCaptureProof = {}; const runtime = createSelectorCaptureRuntime({ + ref: sessionStore.lookup(params.sessionName), device: session.device, - session, sessionStore, sessionName: params.sessionName, consumedSnapshot, @@ -316,10 +316,10 @@ test('a later fact-less capture does not erase an earlier repair proof', async ( function makeCaptureRuntime(sessionName: string) { const sessionStore = makeSessionStore('agent-device-selector-capture-'); const session = makeIosSession(sessionName); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const runtime = createSelectorCaptureRuntime({ + ref: sessionStore.lookup(sessionName), device: session.device, - session, sessionStore, sessionName, capture: boundCapture, @@ -333,3 +333,88 @@ 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, + 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(() => {}); + } +}); + +test('selector capture and sparse recovery both use current same-lifetime app metadata', async () => { + const { runtime, sessionName, sessionStore } = makeCaptureRuntime('selector-current-metadata'); + const ref = sessionStore.lookup(sessionName)!; + sessionStore.update(ref, { appBundleId: 'before-first-capture' }); + boundCapture + .mockImplementationOnce(async () => { + sessionStore.update(ref, { appBundleId: 'before-recovery-capture' }); + return { + backend: 'xctest', + producer: 'apple-runner', + nodes: [{ index: 0, type: 'Application' }], + }; + }) + .mockResolvedValueOnce({ + backend: 'xctest', + producer: 'apple-runner', + nodes: [{ index: 0, type: 'Button', label: 'Recovered' }], + }); + const result = await runtime.capture({ + flags: { snapshotInteractiveOnly: true }, + recovery: { legacyIosSparse: { query: 'Search', shouldScope: false } }, + }); + expect(result.snapshot.nodes[0]?.label).toBe('Recovered'); + expect(boundCapture.mock.calls.map(([input]) => input.options?.appBundleId)).toEqual([ + 'before-first-capture', + 'before-recovery-capture', + ]); + expect(sessionStore.requireCurrent(ref).appBundleId).toBe('before-recovery-capture'); +}); + +test('a retired selector runtime refuses even a reusable cached capture without touching its successor', async () => { + const { runtime, sessionName, sessionStore } = makeCaptureRuntime('selector-retired-cache'); + const ref = sessionStore.lookup(sessionName)!; + await runtime.capture({ flags: {} }); + sessionStore.retire(ref); + const successor = sessionStore.publish(sessionName, ref.session); + await expect(runtime.capture({ flags: {} })).rejects.toThrow( + expect.objectContaining({ + details: expect.objectContaining({ reason: 'session_lifetime_ended' }), + }), + ); + expect(boundCapture).toHaveBeenCalledOnce(); + expect(sessionStore.requireCurrent(successor)).toBe(ref.session); +}); diff --git a/src/daemon/__tests__/selector-recording.test.ts b/src/daemon/__tests__/selector-recording.test.ts index ea319a2ff7..eaa0e0ac49 100644 --- a/src/daemon/__tests__/selector-recording.test.ts +++ b/src/daemon/__tests__/selector-recording.test.ts @@ -46,12 +46,12 @@ test('a repair-armed session excludes get/is/find by default but keeps recording boundary: 0, }, }); - store.set('default', session); + store.publish('default', session); - recordIfSession(store, 'default', req('get'), {}); - recordIfSession(store, 'default', req('is'), {}); - recordIfSession(store, 'default', req('find'), {}); - recordIfSession(store, 'default', req('wait'), {}); + recordIfSession(store, store.lookup('default'), req('get'), {}); + recordIfSession(store, store.lookup('default'), req('is'), {}); + recordIfSession(store, store.lookup('default'), req('find'), {}); + recordIfSession(store, store.lookup('default'), req('wait'), {}); expect(store.get('default')!.actions.map((a) => a.command)).toEqual(['wait']); }); @@ -71,11 +71,11 @@ test('a repair-armed session still records get/is/find dispatched as replay plan boundary: 0, }, }); - store.set('default', session); + store.publish('default', session); - recordIfSession(store, 'default', planStepReq('get'), {}); - recordIfSession(store, 'default', planStepReq('is'), {}); - recordIfSession(store, 'default', planStepReq('find'), {}); + recordIfSession(store, store.lookup('default'), planStepReq('get'), {}); + recordIfSession(store, store.lookup('default'), planStepReq('is'), {}); + recordIfSession(store, store.lookup('default'), planStepReq('find'), {}); expect(store.get('default')!.actions.map((a) => a.command)).toEqual(['get', 'is', 'find']); }); @@ -90,11 +90,11 @@ test('--record forces get/is/find through even while repair-armed', () => { boundary: 0, }, }); - store.set('default', session); + store.publish('default', session); - recordIfSession(store, 'default', req('get', { record: true }), {}); - recordIfSession(store, 'default', req('is', { record: true }), {}); - recordIfSession(store, 'default', req('find', { record: true }), {}); + recordIfSession(store, store.lookup('default'), req('get', { record: true }), {}); + recordIfSession(store, store.lookup('default'), req('is', { record: true }), {}); + recordIfSession(store, store.lookup('default'), req('find', { record: true }), {}); expect(store.get('default')!.actions.map((a) => a.command)).toEqual(['get', 'is', 'find']); }); @@ -103,12 +103,12 @@ test('outside a repair-armed session, get/is/find/wait all record normally', () const store = makeStore(); const session = makeIosSession('default'); expect(session.scriptPublication).toBeUndefined(); - store.set('default', session); + store.publish('default', session); - recordIfSession(store, 'default', req('get'), {}); - recordIfSession(store, 'default', req('is'), {}); - recordIfSession(store, 'default', req('find'), {}); - recordIfSession(store, 'default', req('wait'), {}); + recordIfSession(store, store.lookup('default'), req('get'), {}); + recordIfSession(store, store.lookup('default'), req('is'), {}); + recordIfSession(store, store.lookup('default'), req('find'), {}); + recordIfSession(store, store.lookup('default'), req('wait'), {}); expect(store.get('default')!.actions.map((a) => a.command)).toEqual([ 'get', @@ -120,9 +120,9 @@ test('outside a repair-armed session, get/is/find/wait all record normally', () test('wait absent records positionals without target-v1 annotation', () => { const store = makeStore(); - store.set('default', makeIosSession('default')); + store.publish('default', makeIosSession('default')); - recordIfSession(store, 'default', waitAbsentReq(), { waitedMs: 0 }); + recordIfSession(store, store.lookup('default'), waitAbsentReq(), { waitedMs: 0 }); expect(store.get('default')!.actions[0]).toMatchObject({ command: 'wait', @@ -131,3 +131,12 @@ test('wait absent records positionals without target-v1 annotation', () => { }); expect(store.get('default')!.actions[0]?.targetEvidence).toBeUndefined(); }); + +test('sessionless recording cannot acquire a newly published address', () => { + const store = makeStore(); + const admitted = store.lookup('default'); + const session = makeIosSession('default'); + store.publish('default', session); + recordIfSession(store, admitted, req('wait'), { waitedMs: 100 }); + expect(session.actions).toEqual([]); +}); diff --git a/src/daemon/__tests__/selector-runtime.test.ts b/src/daemon/__tests__/selector-runtime.test.ts index bf16d25932..89f7ed3cb8 100644 --- a/src/daemon/__tests__/selector-runtime.test.ts +++ b/src/daemon/__tests__/selector-runtime.test.ts @@ -1,9 +1,23 @@ -import { beforeEach, test, vi } from 'vitest'; +import { beforeEach, expect, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import { AppError } from '@agent-device/kernel/errors'; import { IOS_SIMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; import { withAppleRunnerProvider } from '@agent-device/platform-apple/runner'; import type { SessionState } from '../session-state.ts'; +import { + localRuntimeOwner, + narrowDeviceBinding, + type DeviceBinding, +} from '@agent-device/contracts/platform-runtime'; +import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; +import { + snapshotRuntimeOperationFacts, + type SnapshotResult, +} from '@agent-device/contracts/snapshot-runtime'; +import { createUnavailableRuntimeFactsForTest } from '../../__tests__/test-utils/runtime-operation-facts.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { makeSession as makeStoredSession } from '../../__tests__/test-utils/session-factories.ts'; +import { dispatchGetViaRuntime } from '../selector-runtime.ts'; const { mockRunAppleRunnerCommand } = vi.hoisted(() => ({ mockRunAppleRunnerCommand: vi.fn(), @@ -19,6 +33,111 @@ function makeSession(): SessionState { return { name: 'default', device: IOS_SIMULATOR, createdAt: Date.now(), actions: [] }; } +test.each(['rebuild', 'retire'] as const)( + 'get text records in its captured lifetime after a held native read across %s', + async (transition) => { + const store = makeSessionStore(); + const address = 'cwd:held-get:default'; + const device = { + platform: 'web', + id: 'web', + name: 'Web', + kind: 'device', + booted: true, + } as const; + const ref = store.publish(address, makeStoredSession('default', { device })); + const owner = localRuntimeOwner('web'); + const base = createUnavailableRuntimeFactsForTest(device, owner); + const available = { available: true } as const; + const facts = { + ...base, + operations: { + ...base.operations, + ...snapshotRuntimeOperationFacts({ + capture: available, + customActions: available, + withoutActiveApp: available, + }), + readTextAtPoint: available, + }, + }; + let startRead!: () => void; + let releaseRead!: () => void; + const reading = new Promise((resolve) => { + startRead = resolve; + }); + const released = new Promise((resolve) => { + releaseRead = resolve; + }); + const capture = async (): Promise => ({ + backend: 'web', + producer: 'agent-browser', + nodes: [ + { index: 0, type: 'Window', rect: { x: 0, y: 0, width: 400, height: 800 } }, + { + index: 1, + parentIndex: 0, + type: 'TextField', + label: 'Input', + rect: { x: 20, y: 20, width: 80, height: 30 }, + hittable: true, + }, + ], + }); + const binding: DeviceBinding = { + device, + owner, + facts, + operations: { + captureSnapshot: capture, + captureSnapshotWithCustomActions: capture, + captureSnapshotWithoutActiveApp: capture, + readTextAtPoint: async () => { + startRead(); + await released; + return { status: 'read', text: 'Native value' }; + }, + }, + [Symbol.asyncDispose]: async () => {}, + }; + const running = dispatchGetViaRuntime({ + req: { + token: 't', + session: 'default', + command: 'get', + positionals: ['text', 'label="Input"'], + flags: {}, + }, + sessionName: address, + sessionStore: store, + inspectFacts: async () => facts, + bindDevice: async (_device, use) => narrowDeviceBinding(binding, use), + }); + await reading; + let current = ref.session; + if (transition === 'rebuild') + current = store.update(ref, { actions: [], appName: 'Rebuilt during read' }); + else { + store.retire(ref); + current = store.publish(address, makeStoredSession('default', { device })).session; + } + releaseRead(); + const response = await running; + if (transition === 'rebuild') { + expect(response).toMatchObject({ ok: true, data: { text: 'Native value' } }); + expect(current.actions.map((action) => action.command)).toEqual(['get']); + expect(current.appName).toBe('Rebuilt during read'); + expect(ref.session.actions).toEqual([]); + } else { + expect(response).toMatchObject({ + ok: false, + error: { details: { reason: 'session_lifetime_ended' } }, + }); + expect(current.actions).toEqual([]); + } + }, +); + async function withRunner(operation: () => Promise): Promise { return await withAppleRunnerProvider( mockRunAppleRunnerCommand, diff --git a/src/daemon/__tests__/session-artifact-paths.test.ts b/src/daemon/__tests__/session-artifact-paths.test.ts new file mode 100644 index 0000000000..c76caa9ffb --- /dev/null +++ b/src/daemon/__tests__/session-artifact-paths.test.ts @@ -0,0 +1,29 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { AppError } from '@agent-device/kernel/errors'; +import { resolveSessionDir } from '../session-artifact-paths.ts'; +import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; + +test('resolveSessionDir keeps every session dir beneath the sessions dir', () => { + const sessionsDir = path.join( + mkdtempForTestSync('agent-device-tests'), + 'agent-device-tests', + 'sessions', + ); + assert.equal(resolveSessionDir(sessionsDir, 'a/b:c d'), path.join(sessionsDir, 'a_b_c_d')); + // `.` and `..` survive `safeSessionName` unchanged, so without an explicit + // refusal `path.join` resolves them to the sessions dir itself and its parent + // (the daemon state dir): a remote caller's `--session ..` would then land + // app.log / runner.log / requests/*.ndjson outside the sessions tree. + for (const name of ['.', '..', '']) { + assert.throws( + () => resolveSessionDir(sessionsDir, name), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + /session name/i.test(error.message), + `expected resolveSessionDir(${JSON.stringify(name)}) to reject`, + ); + } +}); diff --git a/src/daemon/__tests__/session-capture-binding.test.ts b/src/daemon/__tests__/session-capture-binding.test.ts new file mode 100644 index 0000000000..a4a0d5039a --- /dev/null +++ b/src/daemon/__tests__/session-capture-binding.test.ts @@ -0,0 +1,112 @@ +import { expect, test } from 'vitest'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { makeRecordingSession } from './session-teardown.fixtures.ts'; +import { bindSessionScreenRecording } from '../session-capture-binding.ts'; + +test('adoption owns a vacant slot and retains its adopted handle after retirement', () => { + const store = makeSessionStore(); + const recorded = makeRecordingSession({ + name: 'capture', + sessionStore: store, + finish: async () => ({ status: 'cleanup-pending', reason: 'cleanup-unconfirmed' }), + }); + const resource = recorded.screenRecording!; + const ref = store.publish('capture', { ...recorded, screenRecording: undefined }); + const binding = bindSessionScreenRecording(store, ref); + expect(binding.canPersist()).toBe(true); + binding.adopt(resource); + expect(store.requireCurrent(ref).screenRecording).toBe(resource); + expect(binding.canPersist()).toBe(false); + expect(() => binding.adopt(resource)).toThrow( + expect.objectContaining({ + details: expect.objectContaining({ reason: 'session_resource_changed' }), + }), + ); + store.retire(ref); + const successor = store.publish('capture', { + ...recorded, + screenRecording: undefined, + appName: 'successor', + }); + expect(binding.read()).toBe(resource); + expect(binding.clear(resource)).toBe('retired'); + expect(binding.canPersist()).toBe(false); + expect(() => binding.adopt(resource)).toThrow( + expect.objectContaining({ + details: expect.objectContaining({ reason: 'session_lifetime_ended' }), + }), + ); + expect(store.requireCurrent(successor)).toBe(successor.session); + expect(store.requireCurrent(successor).screenRecording).toBeUndefined(); +}); + +test('clearing a capture refreshes a rebuilt record without losing its other changes', () => { + const store = makeSessionStore(); + const session = makeRecordingSession({ + name: 'capture', + sessionStore: store, + finish: async () => ({ status: 'cleanup-pending', reason: 'cleanup-unconfirmed' }), + }); + const ref = store.publish('capture', session); + const binding = bindSessionScreenRecording(store, ref); + const active = binding.read()!; + store.update(ref, { appName: 'updated', screenRecording: { ...active } }); + expect(binding.clear(active)).toBe('cleared'); + expect(store.requireCurrent(ref)).toMatchObject({ + appName: 'updated', + screenRecording: undefined, + }); +}); + +test('clearing an older handle or fence leaves a replacement capture intact', () => { + const store = makeSessionStore(); + const session = makeRecordingSession({ + name: 'capture', + sessionStore: store, + finish: async () => ({ status: 'cleanup-pending', reason: 'cleanup-unconfirmed' }), + }); + const ref = store.publish('capture', session); + const binding = bindSessionScreenRecording(store, ref); + const active = binding.read()!; + const replacement = makeRecordingSession({ + name: 'other', + sessionStore: store, + finish: async () => ({ status: 'cleanup-pending', reason: 'cleanup-unconfirmed' }), + }).screenRecording!; + const differentHandle = { ...active, handle: replacement.handle }; + store.update(ref, { screenRecording: differentHandle }); + expect(binding.clear(active)).toBe('resource-changed'); + expect(binding.read()).toBe(differentHandle); + for (const fence of [ + { ...active.envelope.fence, token: 'next' }, + { ...active.envelope.fence, generation: active.envelope.fence.generation + 1 }, + ]) { + const newerFence = { ...active, envelope: { ...active.envelope, fence } }; + store.update(ref, { screenRecording: newerFence }); + expect(binding.clear(active)).toBe('resource-changed'); + expect(binding.read()).toBe(newerFence); + } +}); + +test('a retired binding retains its old resource but cannot write into the next lifetime', () => { + const store = makeSessionStore(); + const session = makeRecordingSession({ + name: 'capture', + sessionStore: store, + finish: async () => ({ status: 'cleanup-pending', reason: 'cleanup-unconfirmed' }), + }); + const ref = store.publish('capture', session); + const binding = bindSessionScreenRecording(store, ref); + const active = binding.read()!; + store.retire(ref); + const successor = store.publish('capture', session); + expect(binding.read()).toBe(active); + expect(binding.clear(active)).toBe('retired'); + expect(binding.canPersist()).toBe(false); + expect(() => binding.adopt(active)).toThrow( + expect.objectContaining({ + details: expect.objectContaining({ reason: 'session_lifetime_ended' }), + }), + ); + expect(store.requireCurrent(successor).screenRecording).toBe(active); +}); diff --git a/src/daemon/__tests__/session-idle-activity.test.ts b/src/daemon/__tests__/session-idle-activity.test.ts index 02557fdbdf..cb591d4773 100644 --- a/src/daemon/__tests__/session-idle-activity.test.ts +++ b/src/daemon/__tests__/session-idle-activity.test.ts @@ -46,7 +46,7 @@ function makeRequest(overrides: Partial = {}): DaemonRequest { function storeWithSession() { const sessionStore = makeSessionStore('agent-device-idle-activity-'); - sessionStore.set('default', makeIosSession('default')); + sessionStore.publish('default', makeIosSession('default')); return sessionStore; } diff --git a/src/daemon/__tests__/session-idle-expiry-harness.ts b/src/daemon/__tests__/session-idle-expiry-harness.ts index d1e913409c..f92bd4082e 100644 --- a/src/daemon/__tests__/session-idle-expiry-harness.ts +++ b/src/daemon/__tests__/session-idle-expiry-harness.ts @@ -106,7 +106,7 @@ export function createIdleExpiryHarness(): Readonly<{ createdAt: NOW - WINDOW_MS - 1, deviceClaim: { ...CLAIM }, }); - store.set(name, session); + store.publish(name, session); return session; }, // Past its window on time alone, and holding no claim — nothing another agent waits on. @@ -130,7 +130,7 @@ export function createIdleExpiryHarness(): Readonly<{ createdAt: NOW - WINDOW_MS - 1, deviceClaim: acquired.ownership, }); - fixture.sessionStore.set(name, session); + fixture.sessionStore.publish(name, session); return { session, deviceClaim: acquired.ownership }; }, claimFileHeld: (deviceClaim) => fs.existsSync(resolveDeviceClaimPath(deviceClaim.deviceKey)), diff --git a/src/daemon/__tests__/session-routing.test.ts b/src/daemon/__tests__/session-routing.test.ts index 2de5ff75dc..d12a00ea29 100644 --- a/src/daemon/__tests__/session-routing.test.ts +++ b/src/daemon/__tests__/session-routing.test.ts @@ -36,7 +36,7 @@ function makeStore(t: TestContext): SessionStore { test('does not reuse lone active session for implicit default session from another scope', (t) => { const store = makeStore(t); - store.set('android', makeSession('android')); + store.publish('android', makeSession('android')); const cwd = mkdtempForTestSync('agent-device-cwd-scope-'); t.onTestFinished(() => { fs.rmSync(cwd, { recursive: true, force: true }); @@ -223,7 +223,7 @@ test('names an implicit session by the platform it selects', (t) => { test('gives each platform its own implicit session in one workspace', (t) => { const { cwd, scopeId } = makeWorkspaceCwd(t); const store = makeStore(t); - store.set(`cwd:${scopeId}:ios`, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); + store.publish(`cwd:${scopeId}:ios`, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); // #2580: binding Android from the same checkout used to be refused because the workspace // session was already bound to Apple. @@ -239,7 +239,7 @@ test('joins the workspace session that a named platform agrees with', (t) => { const { cwd, scopeId } = makeWorkspaceCwd(t); const store = makeStore(t); const openedWithoutPlatform = `cwd:${scopeId}:default`; - store.set(openedWithoutPlatform, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); + store.publish(openedWithoutPlatform, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); // Routing must keep the store key, so a platform-tagged command writes into the artifacts // of the session it joined instead of a second directory. @@ -251,7 +251,7 @@ test('joins the workspace session that a named platform agrees with', (t) => { test('does not join a workspace session bound to another platform', (t) => { const { cwd, scopeId } = makeWorkspaceCwd(t); const store = makeStore(t); - store.set(`cwd:${scopeId}:ios`, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); + store.publish(`cwd:${scopeId}:ios`, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); const resolved = resolveEffectiveSessionName( implicitRequest(cwd, { platform: 'android', device: 'Pixel 9' }, 'boot'), @@ -265,7 +265,7 @@ test('routes a platform-less request to the workspace sole implicit session', (t const { cwd, scopeId } = makeWorkspaceCwd(t); const store = makeStore(t); const iosSession = `cwd:${scopeId}:ios`; - store.set(iosSession, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); + store.publish(iosSession, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); const resolved = resolveEffectiveSessionName(implicitRequest(cwd, {}, 'press'), store, { attachesToSession: true, @@ -277,8 +277,8 @@ test('routes a platform-less request to the workspace sole implicit session', (t test('refuses a platform-less request when the workspace holds several implicit sessions', (t) => { const { cwd, scopeId } = makeWorkspaceCwd(t); const store = makeStore(t); - store.set(`cwd:${scopeId}:ios`, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); - store.set(`cwd:${scopeId}:android`, makeWorkspaceSession(scopeId, ANDROID_EMULATOR)); + store.publish(`cwd:${scopeId}:ios`, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); + store.publish(`cwd:${scopeId}:android`, makeWorkspaceSession(scopeId, ANDROID_EMULATOR)); assert.throws( () => @@ -303,7 +303,7 @@ test('routes a platform-less request to a sole default-leaf session', (t) => { const { cwd, scopeId } = makeWorkspaceCwd(t); const store = makeStore(t); const openedWithoutPlatform = `cwd:${scopeId}:default`; - store.set(openedWithoutPlatform, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); + store.publish(openedWithoutPlatform, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); const resolved = resolveEffectiveSessionName(implicitRequest(cwd, {}, 'press'), store, { attachesToSession: true, @@ -318,8 +318,8 @@ test('refuses a platform-less request when a default-leaf session shares the wor // The #2580 shape: iOS was opened without `--platform`, so it owns the `default` leaf, and // Android then took its own platform leaf. Preferring `default` here would run a bare `press` // on the iOS device the caller is no longer addressing. - store.set(`cwd:${scopeId}:default`, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); - store.set(`cwd:${scopeId}:android`, makeWorkspaceSession(scopeId, ANDROID_EMULATOR)); + store.publish(`cwd:${scopeId}:default`, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); + store.publish(`cwd:${scopeId}:android`, makeWorkspaceSession(scopeId, ANDROID_EMULATOR)); assert.throws( () => @@ -343,8 +343,8 @@ test('refuses a platform-less request when a default-leaf session shares the wor test('keeps inventory commands routable across implicit session ambiguity', (t) => { const { cwd, scopeId } = makeWorkspaceCwd(t); const store = makeStore(t); - store.set(`cwd:${scopeId}:ios`, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); - store.set(`cwd:${scopeId}:android`, makeWorkspaceSession(scopeId, ANDROID_EMULATOR)); + store.publish(`cwd:${scopeId}:ios`, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); + store.publish(`cwd:${scopeId}:android`, makeWorkspaceSession(scopeId, ANDROID_EMULATOR)); // `session list` is how a caller discovers the two addresses, so it must not be refused. const resolved = resolveEffectiveSessionName(implicitRequest(cwd, {}, 'session_list'), store, { @@ -357,7 +357,10 @@ test('keeps inventory commands routable across implicit session ambiguity', (t) test('keeps a named session out of implicit workspace routing', (t) => { const { cwd, scopeId } = makeWorkspaceCwd(t); const store = makeStore(t); - store.set('qa', { ...makeWorkspaceSession(scopeId, ANDROID_EMULATOR), sessionScope: undefined }); + store.publish('qa', { + ...makeWorkspaceSession(scopeId, ANDROID_EMULATOR), + sessionScope: undefined, + }); const resolved = resolveEffectiveSessionName(implicitRequest(cwd, {}, 'press'), store, { attachesToSession: true, @@ -369,7 +372,10 @@ test('keeps a named session out of implicit workspace routing', (t) => { test('ignores an implicit session owned by another workspace', (t) => { const { cwd } = makeWorkspaceCwd(t); const store = makeStore(t); - store.set('cwd:0000000000000000:ios', makeWorkspaceSession('0000000000000000', IOS_SIMULATOR)); + store.publish( + 'cwd:0000000000000000:ios', + makeWorkspaceSession('0000000000000000', IOS_SIMULATOR), + ); const resolved = resolveEffectiveSessionName(implicitRequest(cwd, {}, 'press'), store, { attachesToSession: true, @@ -410,8 +416,8 @@ test('treats an unusable platform value as no platform at all', (t) => { test('refuses a platform selector that several implicit sessions equally answer', (t) => { const { cwd, scopeId } = makeWorkspaceCwd(t); const store = makeStore(t); - store.set(`cwd:${scopeId}:ios`, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); - store.set(`cwd:${scopeId}:macos`, { + store.publish(`cwd:${scopeId}:ios`, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); + store.publish(`cwd:${scopeId}:macos`, { ...makeWorkspaceSession(scopeId, { platform: 'apple', appleOs: 'macos', @@ -441,8 +447,8 @@ test('prefers the session already keyed to the requested platform', (t) => { const { cwd, scopeId } = makeWorkspaceCwd(t); const store = makeStore(t); // Both sessions answer `--platform ios`: the one keyed to it, and one opened without a platform. - store.set(`cwd:${scopeId}:ios`, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); - store.set( + store.publish(`cwd:${scopeId}:ios`, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); + store.publish( `cwd:${scopeId}:default`, makeWorkspaceSession(scopeId, { ...IOS_SIMULATOR, id: 'SIM-002', name: 'iPhone 16 Plus' }), ); @@ -456,7 +462,7 @@ test('joins the session already keyed to the platform-less default leaf', (t) => const { cwd, scopeId } = makeWorkspaceCwd(t); const store = makeStore(t); const defaultAddress = `cwd:${scopeId}:default`; - store.set(defaultAddress, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); + store.publish(defaultAddress, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); const resolved = resolveEffectiveSessionName(implicitRequest(cwd, {}, 'press'), store, { attachesToSession: true, @@ -469,7 +475,7 @@ test('keeps a same-platform device mismatch on the bound session instead of fork const { cwd, scopeId } = makeWorkspaceCwd(t); const store = makeStore(t); const boundSession = `cwd:${scopeId}:default`; - store.set(boundSession, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); + store.publish(boundSession, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); // `--platform ios` agrees with the bound session; only the device disagrees. Routing hands the // request to that session so the shared selector rules refuse the mismatch, rather than opening @@ -486,7 +492,7 @@ test('keeps a same-platform target mismatch on the bound session', (t) => { const { cwd, scopeId } = makeWorkspaceCwd(t); const store = makeStore(t); const boundSession = `cwd:${scopeId}:default`; - store.set(boundSession, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); + store.publish(boundSession, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); const resolved = resolveEffectiveSessionName( implicitRequest(cwd, { platform: 'ios', target: 'tv' }), @@ -515,7 +521,7 @@ test('routes a session-lock platform into the implicit session key', (t) => { test('routes a session-lock platform past a workspace session bound to another platform', (t) => { const { cwd, scopeId } = makeWorkspaceCwd(t); const store = makeStore(t); - store.set(`cwd:${scopeId}:ios`, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); + store.publish(`cwd:${scopeId}:ios`, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); // Candidate matching that reads only `flags` sees no platform here, so the iOS session appears // to agree and the request runs on it; the lock policy then leaves the platform unset because a @@ -536,10 +542,10 @@ test('counts one session reachable under two addresses once', (t) => { const store = makeStore(t); const address = `cwd:${scopeId}:ios`; const session = makeWorkspaceSession(scopeId, IOS_SIMULATOR); - store.set(address, session); + store.publish(address, session); // A writer that stores by `SessionState.name` publishes the same session a second time. It must // not read as a second session the caller has to disambiguate. - store.set(session.name, session); + store.publish(session.name, session); const resolved = resolveEffectiveSessionName(implicitRequest(cwd, {}, 'press'), store, { attachesToSession: true, diff --git a/src/daemon/__tests__/session-selector.test.ts b/src/daemon/__tests__/session-selector.test.ts index a224a7a059..54e978d994 100644 --- a/src/daemon/__tests__/session-selector.test.ts +++ b/src/daemon/__tests__/session-selector.test.ts @@ -1,3 +1,4 @@ +import { makeStoredSessionRef } from '../../__tests__/test-utils/store-factory.ts'; import { test } from 'vitest'; import assert from 'node:assert/strict'; import { assertSessionSelectorMatches } from '../session-selector.ts'; @@ -24,7 +25,7 @@ function makeSession(overrides?: Partial): SessionState { /** These sessions are explicitly named, so each is stored under — and addressed by — its name. */ function ref(session: SessionState): SessionRef { - return { address: session.name, session }; + return makeStoredSessionRef(session); } test('accepts matching platform and serial selectors', () => { diff --git a/src/daemon/__tests__/session-snapshot.test.ts b/src/daemon/__tests__/session-snapshot.test.ts index b0b64ef0f2..41c9ee2002 100644 --- a/src/daemon/__tests__/session-snapshot.test.ts +++ b/src/daemon/__tests__/session-snapshot.test.ts @@ -1,11 +1,14 @@ import { expect, test } from 'vitest'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import type { SettleObservation } from '@agent-device/contracts/interaction'; import type { SnapshotState } from '@agent-device/kernel/snapshot'; import type { SessionState } from '../session-state.ts'; import { markSessionPartialRefsIssued, + issueSettleRefs, resolveRefStalenessWarning, setSessionSnapshot, - setSnapshotLineage, + setCommandSnapshot, STALE_SNAPSHOT_REFS_WARNING, } from '../session-snapshot.ts'; import { @@ -179,11 +182,14 @@ 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, { + const diffSnapshot = makeSnapshot(); + setCommandSnapshot(afterDiff, { + snapshot: diffSnapshot, scopeSource: undefined, keptCurrentSnapshot: false, previousGeneration: session.snapshotGeneration, }); + expect(afterDiff.snapshot).toBe(diffSnapshot); expect(afterDiff.snapshotGeneration).not.toBe(session.snapshotGeneration); expect(refFrameEpoch(afterDiff)).toBe(issuedAt); @@ -211,10 +217,44 @@ 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, }); expect(session.snapshotGeneration).toBe(before); }); + +for (const retire of [false, true]) { + test(`settle ref issuance checks lifetime before publishing (${retire ? 'retired' : 'live'})`, () => { + const store = makeSessionStore(); + const session = makeSession(); + setSessionSnapshot(session, makeSnapshot()); + const ref = store.publish('cwd:settle:default', session); + const priorFrame = refFrame(session); + if (retire) { + store.retire(ref); + store.publish(ref.address, session); + } + const observation: SettleObservation = { + settled: true, + waitedMs: 1, + captures: 2, + quietMs: 1, + timeoutMs: 20, + diff: { + summary: { additions: 1, removals: 0, unchanged: 0 }, + lines: [{ kind: 'added', text: 'new button', ref: 'e1' }], + }, + }; + const generation = issueSettleRefs(ref, store, observation); + if (retire) { + expect(generation).toBeUndefined(); + expect(refFrame(session)).toBe(priorFrame); + } else { + expect(generation).toBe(session.snapshotGeneration); + expect(refFrameScope(session)).toEqual(new Set(['e1'])); + } + }); +} diff --git a/src/daemon/__tests__/session-store-lifetime.test.ts b/src/daemon/__tests__/session-store-lifetime.test.ts new file mode 100644 index 0000000000..80ca833207 --- /dev/null +++ b/src/daemon/__tests__/session-store-lifetime.test.ts @@ -0,0 +1,260 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { test } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; +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'; + +function ended(error: unknown): boolean { + return error instanceof AppError && error.details?.reason === 'session_lifetime_ended'; +} + +test('refs capture records while resolving rebuilds from the same lifetime', () => { + const store = makeSessionStore(); + const session = makeSession('default'); + const initial = store.publish(ADDRESS, session); + const lookup = store.lookup(ADDRESS)!; + const listed = store.listRefs()[0]!; + const byDevice = store.findByDevice(session.device.id)!; + for (const ref of [lookup, listed, byDevice]) { + assert.notEqual(ref, initial); + assert.equal(ref.lifetime, initial.lifetime); + assert.equal(ref.session, session); + assert.equal(Object.isFrozen(ref), true); + } + const rebuilt = store.update(initial, { appName: 'Reopened' }); + assert.equal(initial.session, session); + assert.equal(initial.session.appName, undefined); + for (const ref of [initial, lookup, listed, byDevice]) { + assert.equal(store.resolveCurrent(ref), rebuilt); + } + assert.equal(store.lookup(ADDRESS)?.session, rebuilt); + const refreshed = store.refresh(initial); + assert.equal(refreshed.lifetime, initial.lifetime); + assert.equal(refreshed.session, rebuilt); + assert.equal(initial.session, session); + assert.equal(store.get('default'), undefined); +}); + +test('updates derive from the latest matching record and preserve intervening fields', () => { + const store = makeSessionStore(); + const ref = store.publish(ADDRESS, makeSession('default', { createdAt: 10 })); + store.update(ref, { appBundleId: 'com.example.updated' }); + store.update(ref, (current) => ({ + appName: current.appBundleId, + createdAt: current.createdAt + 1, + })); + assert.equal(store.get(ADDRESS)?.appBundleId, 'com.example.updated'); + assert.equal(store.get(ADDRESS)?.appName, 'com.example.updated'); + assert.equal(store.get(ADDRESS)?.createdAt, 11); + assert.equal(store.get(ADDRESS)?.actions, ref.session.actions); +}); + +test('address reuse with the same record still starts a different lifetime', () => { + const store = makeSessionStore(); + const session = makeSession('default'); + const old = store.publish(ADDRESS, session); + assert.equal(store.retire(old), true); + const successor = store.publish(ADDRESS, session); + store.setRuntimeHints(ADDRESS, { metroPort: 8082 }); + assert.notEqual(successor.lifetime, old.lifetime); + assert.equal(store.resolveCurrent(old), undefined); + assert.equal(store.refresh(old), old); + assert.throws(() => store.requireCurrent(old), ended); + assert.throws(() => store.update(old, { appName: 'Stale' }), ended); + assert.equal(store.retire(old), false); + assert.equal(store.get(ADDRESS), session); + assert.equal(store.getRuntimeHints(ADDRESS)?.metroPort, 8082); + assert.equal(store.retire(successor), true); + assert.equal(store.getRuntimeHints(ADDRESS), undefined); +}); + +test('retired updates cannot run their derivation or resurrect a record', () => { + const store = makeSessionStore(); + const ref = store.publish(ADDRESS, makeSession('default')); + store.retire(ref); + let ran = false; + assert.throws( + () => + store.update(ref, () => { + ran = true; + return { appName: 'Late' }; + }), + ended, + ); + assert.equal(ran, false); + assert.equal(store.lookup(ADDRESS), undefined); +}); + +test('an occupied address cannot be published again', () => { + const store = makeSessionStore(); + const ref = store.publish(ADDRESS, makeSession('default')); + assert.throws( + () => store.publish(ADDRESS, makeSession('default')), + (error) => error instanceof AppError && error.details?.reason === 'session_address_occupied', + ); + assert.equal(store.requireCurrent(ref), ref.session); +}); + +test('shutdown closes draft admission while allowing the current lifetime to settle', () => { + const store = makeSessionStore(); + const ref = store.publish(ADDRESS, makeSession('default')); + store.closeAdmission(); + assert.throws( + () => store.publish('late-draft', makeSession('late-draft')), + (error) => error instanceof AppError && error.details?.reason === 'daemon_shutting_down', + ); + store.update(ref, { appName: 'Settled' }); + assert.equal(store.requireCurrent(ref).appName, 'Settled'); + assert.equal(store.retire(ref), true); + assert.equal(store.lookup('late-draft'), undefined); +}); + +test('a ref from another store has no authority over the same address', () => { + const source = makeSessionStore(); + const target = makeSessionStore(); + const foreign = source.publish(ADDRESS, makeSession('default')); + const local = target.publish(ADDRESS, foreign.session); + assert.equal(target.resolveCurrent(foreign), undefined); + assert.throws(() => target.update(foreign, { appName: 'Foreign' }), ended); + 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('action recording uses the latest matching record and its scoped journal', async () => { + const store = makeSessionStore(); + const ref = store.publish(ADDRESS, makeSession('default')); + const publicSlot = store.publish('default', makeSession('default')); + const current = store.update(ref, { actions: [] }); + store.recordAction(ref, { + command: 'click', + positionals: ['id="continue"'], + flags: { saveScript: true }, + }); + await store.flushEvents(); + assert.deepEqual( + current.actions.map((action) => action.command), + ['click'], + ); + assert.equal(ref.session.actions.length, 0); + assert.equal(ref.session.scriptPublication, undefined); + assert.equal(current.scriptPublication?.kind, 'authoring'); + assert.equal(publicSlot.session.actions.length, 0); + assert.equal(fs.existsSync(store.resolveEventLogPath('default')), false); + assert.equal(store.readEvents(ADDRESS).events[0]?.session, ADDRESS); +}); + +test.each([{}, { noRecord: true }])( + 'retired action recording refuses before flags or journal writes, flags=%j', + async (flags) => { + const store = makeSessionStore(); + const session = makeSession('default'); + const ref = store.publish(ADDRESS, session); + store.retire(ref); + const successor = store.publish(ADDRESS, session); + assert.throws( + () => + store.recordAction(ref, { + command: 'click', + positionals: ['id="continue"'], + flags: { saveScript: true, ...flags }, + }), + ended, + ); + await store.flushEvents(); + assert.equal(store.requireCurrent(successor), session); + assert.equal(session.actions.length, 0); + assert.equal(session.scriptPublication, undefined); + assert.equal(fs.existsSync(store.resolveEventLogPath(ADDRESS)), false); + assert.equal(fs.existsSync(store.resolveEventLogPath('default')), false); + }, +); + +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 05fe386324..1258c6d85a 100644 --- a/src/daemon/__tests__/session-store.test.ts +++ b/src/daemon/__tests__/session-store.test.ts @@ -1,10 +1,8 @@ +import { storeSessionForTest } from '../../__tests__/test-utils/store-factory.ts'; import { test } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; -// oxlint-disable-next-line no-restricted-imports -- asserts a path under os.homedir -import os from 'node:os'; import path from 'node:path'; -import { AppError } from '@agent-device/kernel/errors'; import { SessionStore } from '../session-store.ts'; import type { SessionState } from '../session-state.ts'; import { buildRequestFinishedEvent } from '@agent-device/session-journal/session-event-log'; @@ -67,7 +65,7 @@ function recordOpen( flags: RecordActionEntry['flags'] = { platform: 'ios', saveScript: true }, runtime?: RecordActionEntry['runtime'], ): void { - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'open', positionals: ['Settings'], flags, @@ -77,7 +75,7 @@ function recordOpen( } function recordClose(store: SessionStore, session: SessionState): void { - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'close', positionals: [], flags: { platform: 'ios' }, @@ -86,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); } @@ -96,19 +94,6 @@ function assertScriptMatches(script: string, patterns: RegExp[]): void { } } -test('expandHome resolves tilde, relative-with-cwd, and absolute paths', () => { - const homePath = SessionStore.expandHome('~/flows/replay.ad'); - assert.equal(homePath.startsWith(os.homedir()), true); - assert.equal(homePath.endsWith(path.join('flows', 'replay.ad')), true); - - const relativePath = SessionStore.expandHome('workflows/replay.ad', '/tmp/agent-device-cwd'); - assert.equal(relativePath, path.resolve('/tmp/agent-device-cwd', 'workflows/replay.ad')); - - const absoluteInput = path.resolve('/tmp', 'agent-device-absolute.ad'); - const absolutePath = SessionStore.expandHome(absoluteInput, '/tmp/ignored-cwd'); - assert.equal(absolutePath, absoluteInput); -}); - test('defaultTracePath sanitizes session name', () => { const store = new SessionStore( path.join(mkdtempForTestSync('agent-device-tests'), 'agent-device-tests'), @@ -119,30 +104,6 @@ test('defaultTracePath sanitizes session name', () => { assert.match(tracePath, /\.trace\.log$/); }); -test('resolveSessionDir keeps every session dir beneath the sessions dir', () => { - const sessionsDir = path.join( - mkdtempForTestSync('agent-device-tests'), - 'agent-device-tests', - 'sessions', - ); - const store = new SessionStore(sessionsDir); - assert.equal(store.resolveSessionDir('a/b:c d'), path.join(sessionsDir, 'a_b_c_d')); - // `.` and `..` survive `safeSessionName` unchanged, so without an explicit - // refusal `path.join` resolves them to the sessions dir itself and its parent - // (the daemon state dir): a remote caller's `--session ..` would then land - // app.log / runner.log / requests/*.ndjson outside the sessions tree. - for (const name of ['.', '..', '']) { - assert.throws( - () => store.resolveSessionDir(name), - (error: unknown) => - error instanceof AppError && - error.code === 'INVALID_ARGS' && - /session name/i.test(error.message), - `expected resolveSessionDir(${JSON.stringify(name)}) to reject`, - ); - } -}); - test('session lease metadata round-trips through the store', () => { const { store, session } = makeFixture('agent-device-session-lease-'); session.lease = { @@ -156,7 +117,7 @@ test('session lease metadata round-trips through the store', () => { expiresAt: 123_456, }; - store.set(session.name, session); + store.publish(session.name, session); assert.deepEqual(store.get(session.name)?.lease, session.lease); }); @@ -164,7 +125,7 @@ test('session lease metadata round-trips through the store', () => { test('sessions without lease metadata remain valid', () => { const { store, session } = makeFixture('agent-device-session-unleased-'); - store.set(session.name, session); + store.publish(session.name, session); assert.equal(store.get(session.name)?.lease, undefined); }); @@ -174,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); }); @@ -182,7 +143,7 @@ test('parameterized fill publication writes only the placeholder to target and t const fixture = makeFixture('agent-device-session-log-parameterized-fill-'); const secret = 'publication-only-live-value-1348'; recordOpen(fixture.store, fixture.session); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'fill', positionals: ['id="password"', secret], flags: { platform: 'ios', recordAs: 'PASSWORD' }, @@ -206,7 +167,7 @@ test('parameterized fill publication writes only the placeholder to target and t test('recordAction writes a paged session event log', async () => { const { store, session } = makeFixture('agent-device-session-events-'); recordOpen(store, session, { platform: 'ios' }); - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'click', positionals: ['@14', 'Checkout'], flags: { platform: 'ios' }, @@ -228,7 +189,7 @@ test('recordAction writes a paged session event log', async () => { test('recordAction event log redacts typed text and its length from display positionals', async () => { const { store, session } = makeFixture('agent-device-session-events-redaction-'); - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'fill', positionals: ['@14', 'super-secret-token'], flags: {}, @@ -252,25 +213,25 @@ test('recordAction event log redacts payload-bearing and unknown positionals', a const eventPayload = '{"token":"event-secret-token"}'; const futurePayload = 'future-secret-token'; - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'clipboard', positionals: ['write', clipboardText], flags: {}, result: { action: 'write', textLength: Array.from(clipboardText).length }, }); - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'push', positionals: ['com.example.app', pushPayload], flags: {}, result: { message: 'Pushed notification to com.example.app' }, }); - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'trigger-app-event', positionals: ['checkout', eventPayload], flags: {}, result: { message: 'Triggered app event checkout' }, }); - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'future-command', positionals: ['public-ish', futurePayload], flags: {}, @@ -295,13 +256,13 @@ test('recordAction event log redacts payload-bearing and unknown positionals', a test('recordAction event log omits transformed messages for redacted positionals', async () => { const { store, session } = makeFixture('agent-device-session-events-overlap-redaction-'); - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'future-command', positionals: ['token', 'my-token-123'], flags: {}, result: { message: 'Ran my-token-123 after token' }, }); - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'future-command', positionals: ['arg', 'my-arg-123'], flags: {}, @@ -323,7 +284,7 @@ test('recordAction event log omits transformed messages for redacted positionals test('recordAction event log does not leak short typed text or its length', async () => { const { store, session } = makeFixture('agent-device-session-events-short-text-'); - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'type', positionals: ['e'], flags: {}, @@ -341,7 +302,7 @@ test('recordAction event log does not leak short typed text or its length', asyn test('recordAction event log omits value-bearing selector details', async () => { const { store, session } = makeFixture('agent-device-session-events-selector-redaction-'); - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'click', positionals: ['value=123456'], flags: {}, @@ -368,7 +329,7 @@ test('recordAction event log omits value-bearing selector details', async () => test('recordAction event log rejects malformed provider scroll output', async () => { const { store, session } = makeFixture('agent-device-session-events-malformed-scroll-'); const privateValue = 'provider-private-scroll-value'; - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'scroll', positionals: [privateValue], flags: {}, @@ -437,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); @@ -455,7 +416,7 @@ test('writeSessionLog persists open --relaunch in script output', () => { test('writeSessionLog persists record --hide-touches flags in script output', () => { const fixture = makeFixture('agent-device-session-log-record-'); recordOpen(fixture.store, fixture.session); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'record', positionals: ['start', './capture.mp4'], flags: { @@ -474,7 +435,7 @@ test('writeSessionLog persists record --hide-touches flags in script output', () test('writeSessionLog persists screenshot flags in script output', () => { const fixture = makeFixture('agent-device-session-log-screenshot-'); recordOpen(fixture.store, fixture.session); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'screenshot', positionals: ['./page.png'], flags: { platform: 'ios', screenshotFullscreen: true, screenshotScale: 0.3 }, @@ -510,7 +471,7 @@ test('writeSessionLog persists inline open runtime hints in script output', () = test('writeSessionLog persists runtime set hints in script output', () => { const fixture = makeFixture('agent-device-session-log-runtime-'); recordOpen(fixture.store, fixture.session); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'runtime', positionals: ['set'], flags: { @@ -533,7 +494,7 @@ test('writeSessionLog persists runtime set hints in script output', () => { test('writeSessionLog preserves interaction series flags for click/press/swipe', () => { const fixture = makeFixture('agent-device-session-log-series-flags-'); recordOpen(fixture.store, fixture.session); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'click', positionals: ['id="continue_button"'], flags: { @@ -546,7 +507,7 @@ test('writeSessionLog preserves interaction series flags for click/press/swipe', }, result: {}, }); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'press', positionals: ['201', '545'], flags: { @@ -556,7 +517,7 @@ test('writeSessionLog preserves interaction series flags for click/press/swipe', }, result: {}, }); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'swipe', positionals: ['10', '20', '30', '40'], flags: { @@ -567,7 +528,7 @@ test('writeSessionLog preserves interaction series flags for click/press/swipe', }, result: {}, }); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'fill', positionals: ['@e5', 'search'], flags: { @@ -576,7 +537,7 @@ test('writeSessionLog preserves interaction series flags for click/press/swipe', }, result: {}, }); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'gesture', positionals: ['pan', '195', '443', '48', '0', '500'], flags: { @@ -600,19 +561,19 @@ test('writeSessionLog preserves interaction series flags for click/press/swipe', test('writeSessionLog optimizes selector chains and scopes fallback snapshots', () => { const fixture = makeFixture('agent-device-session-log-selectors-'); recordOpen(fixture.store, fixture.session); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'snapshot', positionals: [], flags: { platform: 'ios', snapshotInteractiveOnly: true }, result: {}, }); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'click', positionals: ['@e1'], flags: { platform: 'ios', count: 2 }, result: { selectorChain: ['text="Continue"', 'role=button'], refLabel: 'Continue' }, }); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'longpress', positionals: ['@e3', '800'], flags: { platform: 'ios' }, @@ -622,13 +583,13 @@ test('writeSessionLog optimizes selector chains and scopes fallback snapshots', }, }); // #1783: hover @ref publishes as a portable selector line like click/longpress. - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'hover', positionals: ['@e4~s12'], flags: { platform: 'web', settle: true }, result: { selectorChain: ['text="Second message"', 'role=link'] }, }); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'fill', positionals: ['@e2', 'hello world'], flags: { platform: 'ios', delayMs: 5 }, @@ -670,19 +631,19 @@ test('writeSessionLog preserves significant whitespace and empty string argument launchUrl: 'myapp://dev ', }, ); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'type', positionals: [' leading\ttrailing '], flags: { platform: 'ios' }, result: {}, }); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'fill', positionals: ['@e5', ''], flags: { platform: 'ios' }, result: { refLabel: 'Search field' }, }); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'screenshot', positionals: [' ./screens/final.png '], flags: { platform: 'ios' }, @@ -717,7 +678,7 @@ const SAVE_TARGET_EVIDENCE: TargetAnnotationV1 = { test('writeSessionLog emits the target-v1 annotation immediately before its action line', () => { const fixture = makeFixture('agent-device-session-log-target-evidence-'); recordOpen(fixture.store, fixture.session); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'click', positionals: ['@e12'], flags: { platform: 'ios' }, @@ -739,7 +700,7 @@ test('writeSessionLog emits the target-v1 annotation immediately before its acti test('writeSessionLog never fabricates a target-v1 annotation for actions recorded without evidence', () => { const fixture = makeFixture('agent-device-session-log-no-target-evidence-'); recordOpen(fixture.store, fixture.session); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'click', positionals: ['@e12'], flags: { platform: 'ios' }, @@ -763,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'); @@ -776,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); }); @@ -784,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'); @@ -818,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. @@ -859,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, @@ -887,7 +848,7 @@ test('BLOCKER 3: finalizeRepairTeardown auto-commit records a terminal close, pr test('noteSessionActivity stamps the live record and ignores an unknown address', () => { const { store, session } = makeFixture('agent-device-store-note-activity-'); - store.set('default', session); + store.publish('default', session); assert.equal(session.lastActivityAtMs, undefined); store.noteSessionActivity('default', 5_000); diff --git a/src/daemon/__tests__/snapshot-command-runtime.test.ts b/src/daemon/__tests__/snapshot-command-runtime.test.ts index b43dafba60..347ad83554 100644 --- a/src/daemon/__tests__/snapshot-command-runtime.test.ts +++ b/src/daemon/__tests__/snapshot-command-runtime.test.ts @@ -39,7 +39,7 @@ for (const command of ['snapshot', 'diff snapshot'] as const) { test(`${command} forwards request cancellation into snapshot dispatch`, async () => { const sessionName = 'default'; const sessionStore = makeSessionStore('agent-device-snapshot-cancellation-'); - sessionStore.set(sessionName, makeAndroidSession(sessionName)); + sessionStore.publish(sessionName, makeAndroidSession(sessionName)); const requestId = `snapshot-cancellation-${command.replace(' ', '-')}`; const registration = registerRequestAbort(requestId); if (!registration) throw new Error('expected request abort registration'); @@ -85,3 +85,94 @@ for (const command of ['snapshot', 'diff snapshot'] as const) { } }); } + +for (const change of ['unchanged', '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 !== 'replace') { + const trace = + change === 'rebuild' ? { outPath: 'intervening-trace', startedAt: 1 } : ref.session.trace; + if (change === 'rebuild') sessionStore.update(ref, { trace }); + const priorRecord = sessionStore.requireCurrent(ref); + release.resolve(); + expect(await result).toMatchObject({ response: { ok: true } }); + const current = sessionStore.requireCurrent(ref); + expect(current.trace).toBe(trace); + expect(current).not.toBe(priorRecord); + expect(priorRecord.snapshot).toBeUndefined(); + expect(ref.session.snapshot).toBeUndefined(); + 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; + } + }); +} + +test('a composed snapshot refuses its supplied retired lifetime before facts or capture', async () => { + const sessionStore = makeSessionStore(); + const address = 'cwd:composed-snapshot:default'; + const ref = sessionStore.publish(address, makeAndroidSession('default')); + sessionStore.retire(ref); + const successor = sessionStore.publish(address, makeAndroidSession('default')); + const fixture = snapshotRuntimeFixture(); + const facts = fixture.inspectFacts; + let inspections = 0; + captureMock.mockResolvedValue({ nodes: [], truncated: false, backend: 'uiautomator' }); + await expect( + dispatchSnapshotViaRuntime({ + req: { command: 'snapshot', positionals: [], token: 't', session: 'default' }, + sessionName: address, + sessionRef: ref, + logPath: '/dev/null', + sessionStore, + ...fixture, + inspectFacts: async (device) => { + inspections++; + return await facts(device); + }, + }), + ).rejects.toMatchObject({ details: { reason: 'session_lifetime_ended' } }); + expect(inspections).toBe(0); + expect(captureMock).not.toHaveBeenCalled(); + expect(sessionStore.requireCurrent(successor)).toBe(successor.session); +}); diff --git a/src/daemon/__tests__/snapshot-custom-actions-platform-guard.test.ts b/src/daemon/__tests__/snapshot-custom-actions-platform-guard.test.ts index 07e2da8d76..bb5b8e9767 100644 --- a/src/daemon/__tests__/snapshot-custom-actions-platform-guard.test.ts +++ b/src/daemon/__tests__/snapshot-custom-actions-platform-guard.test.ts @@ -44,7 +44,7 @@ function snapshotDeviceRuntimeGateway(): DeviceRuntimeGateway { const harness = await runtimeHarness({}); const sessionStore = makeSessionStore('agent-device-diff-runtime-plan-'); - sessionStore.set('diff-runtime', { + sessionStore.publish('diff-runtime', { ...harness.session, ...(activeApp ? { appBundleId: 'com.example.app' } : {}), }); @@ -181,7 +181,7 @@ test('preserves initialized, unchanged, and changed diff results through one bou captures: [baseCapture, baseCapture, changedCapture], }); const sessionStore = makeSessionStore('agent-device-diff-runtime-results-'); - sessionStore.set('diff-runtime', { ...harness.session, appBundleId: 'com.example.app' }); + sessionStore.publish('diff-runtime', { ...harness.session, appBundleId: 'com.example.app' }); const run = async () => await dispatchSnapshotDiffViaRuntime({ @@ -230,7 +230,7 @@ test('a sparse internal diff capture returns no screenshot fallback artifact', a ], }); const sessionStore = makeSessionStore('agent-device-diff-runtime-sparse-'); - sessionStore.set('diff-runtime', { ...harness.session, appBundleId: 'com.example.app' }); + sessionStore.publish('diff-runtime', { ...harness.session, appBundleId: 'com.example.app' }); const response = await dispatchSnapshotDiffViaRuntime({ req: { command: 'diff', positionals: ['snapshot'], token: 't', session: 'diff-runtime' }, diff --git a/src/daemon/__tests__/snapshot-quality-latch.test.ts b/src/daemon/__tests__/snapshot-quality-latch.test.ts index 337bf2e031..5a5ba0616b 100644 --- a/src/daemon/__tests__/snapshot-quality-latch.test.ts +++ b/src/daemon/__tests__/snapshot-quality-latch.test.ts @@ -190,7 +190,7 @@ function scenario() { const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; const session = makeIosSession(sessionName, { appBundleId: 'com.example.app' }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); return { sessionStore, sessionName, logPath: path.join(root, 'daemon.log') }; } diff --git a/src/daemon/__tests__/snapshot-runtime-binding.test.ts b/src/daemon/__tests__/snapshot-runtime-binding.test.ts index b512636855..3b87f1b483 100644 --- a/src/daemon/__tests__/snapshot-runtime-binding.test.ts +++ b/src/daemon/__tests__/snapshot-runtime-binding.test.ts @@ -29,7 +29,7 @@ test('the owning interface binds exactly the session device the facts were admit }; const sessionStore = makeSessionStore(); // An active app: the fixture admits captureSnapshot for a local iOS simulator only with one. - sessionStore.set( + sessionStore.publish( 'bind-test', makeIosSession('bind-test', { device: IOS_SIMULATOR, appBundleId: 'com.example.app' }), ); diff --git a/src/daemon/__tests__/snapshot-runtime-disclosure.test.ts b/src/daemon/__tests__/snapshot-runtime-disclosure.test.ts index 75e59093d1..4ce0805023 100644 --- a/src/daemon/__tests__/snapshot-runtime-disclosure.test.ts +++ b/src/daemon/__tests__/snapshot-runtime-disclosure.test.ts @@ -159,7 +159,7 @@ function scenario(params: { storedRepair?: boolean }) { iosSystemSurfaceBundleId: 'com.apple.SafariViewService', }; } - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); return { sessionStore, sessionName, logPath: path.join(root, 'daemon.log') }; } diff --git a/src/daemon/__tests__/sparse-fallback-screenshot.test.ts b/src/daemon/__tests__/sparse-fallback-screenshot.test.ts index a4a1a8d00a..703c779cdb 100644 --- a/src/daemon/__tests__/sparse-fallback-screenshot.test.ts +++ b/src/daemon/__tests__/sparse-fallback-screenshot.test.ts @@ -25,7 +25,10 @@ async function scenario() { const root = await mkdtempForTest('agent-device-sparse-fallback-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); return { sessionStore, sessionName, logPath: path.join(root, 'daemon.log') }; } diff --git a/src/daemon/__tests__/system-button-runtime.test.ts b/src/daemon/__tests__/system-button-runtime.test.ts index f6e1b49a12..a95d1f0f7b 100644 --- a/src/daemon/__tests__/system-button-runtime.test.ts +++ b/src/daemon/__tests__/system-button-runtime.test.ts @@ -170,7 +170,7 @@ test('request router joins home admission to execution, recording, and ref inval const sessionStore = makeSessionStore('agent-device-home-generic-'); const session = makeSession('system-button-runtime', { device: iosSimulator }); activateCompleteRefFrame(session); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const handler = createRequestHandler({ logPath: '/tmp/daemon.log', token: 't', diff --git a/src/daemon/__tests__/tv-remote-runtime.test.ts b/src/daemon/__tests__/tv-remote-runtime.test.ts index 17fdb7713c..171cecb8b6 100644 --- a/src/daemon/__tests__/tv-remote-runtime.test.ts +++ b/src/daemon/__tests__/tv-remote-runtime.test.ts @@ -214,7 +214,7 @@ test('request router joins tv-remote admission to execution and ref invalidation const sessionStore = makeSessionStore('agent-device-tv-remote-generic-'); const session = makeSession('tv-remote-runtime', { device: vegaVvd }); activateCompleteRefFrame(session); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const handler = createRequestHandler({ logPath: '/tmp/daemon.log', token: 't', diff --git a/src/daemon/__tests__/viewport-runtime.test.ts b/src/daemon/__tests__/viewport-runtime.test.ts index 08235b3961..e48fc75d0f 100644 --- a/src/daemon/__tests__/viewport-runtime.test.ts +++ b/src/daemon/__tests__/viewport-runtime.test.ts @@ -165,7 +165,7 @@ test('request router joins viewport admission to execution, recording, and ref i const sessionStore = makeSessionStore('agent-device-viewport-generic-'); const session = makeSession('viewport-runtime', { device: webDevice }); activateCompleteRefFrame(session); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const handler = createRequestHandler({ logPath: '/tmp/daemon.log', token: 't', diff --git a/src/daemon/__tests__/wait-runtime.test.ts b/src/daemon/__tests__/wait-runtime.test.ts index 4916c05ee9..01225ab095 100644 --- a/src/daemon/__tests__/wait-runtime.test.ts +++ b/src/daemon/__tests__/wait-runtime.test.ts @@ -148,6 +148,55 @@ function waitRequest(positionals: string[], flags: Record = {}) } as unknown as DaemonRequest; } +test.each(['rebuild', 'retire'] as const)( + 'sleep-only wait records in its admitted lifetime across %s', + async (transition) => { + vi.useFakeTimers(); + try { + const harness = waitRuntimeHarness(); + const sessionStore = makeSessionStore(); + const address = 'cwd:sleep-wait:default'; + const ref = sessionStore.publish(address, makeSession('default', { device: harness.device })); + const running = handleSnapshotCommands({ + req: waitRequest(['100']), + sessionName: address, + logPath: '/tmp/daemon.log', + sessionStore, + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }); + await vi.advanceTimersByTimeAsync(0); + let current = ref.session; + if (transition === 'rebuild') { + current = sessionStore.update(ref, { actions: [], appName: 'Updated during sleep' }); + } else { + sessionStore.retire(ref); + current = sessionStore.publish( + address, + makeSession('default', { device: harness.device }), + ).session; + } + await vi.advanceTimersByTimeAsync(100); + const response = await running; + expect(harness.captureSnapshot).not.toHaveBeenCalled(); + if (transition === 'rebuild') { + expect(response?.ok).toBe(true); + expect(current.actions.map((action) => action.command)).toEqual(['wait']); + expect(ref.session.actions).toEqual([]); + expect(current.appName).toBe('Updated during sleep'); + } else { + expect(response).toMatchObject({ + ok: false, + error: { details: { reason: 'session_lifetime_ended' } }, + }); + expect(current.actions).toEqual([]); + } + } finally { + vi.useRealTimers(); + } + }, +); + async function runWait( positionals: string[], harness: ReturnType, @@ -163,7 +212,7 @@ async function runWait( backend: 'web', } as unknown as NonNullable; } - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const response = await handleSnapshotCommands({ req: waitRequest(positionals, flags), sessionName: session.name, diff --git a/src/daemon/app-log-session-resource.ts b/src/daemon/app-log-session-resource.ts index e86d50f165..1099c5811e 100644 --- a/src/daemon/app-log-session-resource.ts +++ b/src/daemon/app-log-session-resource.ts @@ -15,7 +15,8 @@ import { } from '@agent-device/capture-kit/durable-capture-resource'; import { appLogResourceStore } from './app-log-resource-store.ts'; import type { SessionStore } from './session-store.ts'; -import type { SessionState } from './session-state.ts'; +import type { SessionRef, SessionState } from './session-state.ts'; +import { bindSessionCapture } from './session-capture-binding.ts'; export type AppLogSessionSnapshot = Readonly<{ active: boolean; @@ -30,16 +31,11 @@ export type AppLogSessionSnapshot = Readonly<{ export const appLogDurableResource = createDurableCaptureResource< 'app-log', AppLogLiveHandle, - AppLogCompletion, - SessionState + AppLogCompletion >({ resourceKind: 'app-log', displayName: 'app-log', store: appLogResourceStore, - sessionSlot: { - read: (session) => session.appLog, - replace: (session, appLog) => ({ ...session, appLog, appLogFailure: undefined }), - }, completionMetadata: (completion) => ({ backend: completion.backend, outputPath: completion.outputPath, @@ -76,10 +72,8 @@ export function inspectSessionAppLog(session: SessionState): AppLogSessionSnapsh export function adoptStartedSessionAppLog(params: { admissionLedger: AppLogAdmissionLedger; - session: SessionState; - sessionName: string; + ref: SessionRef; sessionStore: SessionStore; - resourcePath: string; device: DeviceInfo; owner: RuntimeOwnerRef; fence: ResourceOwnershipFence; @@ -87,38 +81,42 @@ export function adoptStartedSessionAppLog(params: { envelope: DurableResourceEnvelope<'app-log'>; throwIfCanceled(): void; }): Promise { - return appLogDurableResource.adoptStarted(params); + return appLogDurableResource.adoptStarted({ + ...params, + binding: bindSessionAppLog(params.sessionStore, params.ref), + }); } export function finishSessionAppLog(params: { - session: SessionState; - sessionName: string; + ref: SessionRef; sessionStore: SessionStore; - resourcePath: string; intent: DurableCaptureFinishIntent; }): Promise { - return appLogDurableResource.finishLive(params); + return appLogDurableResource.finishLive({ + binding: bindSessionAppLog(params.sessionStore, params.ref), + intent: params.intent, + }); } export function forceCleanupSessionAppLog(params: { - session: SessionState; - sessionName?: string; - sessionStore?: SessionStore; - resourcePath: string; + ref: SessionRef; + sessionStore: SessionStore; }): Promise { - return appLogDurableResource.forceCleanupLive(params); + return appLogDurableResource.forceCleanupLive({ + binding: bindSessionAppLog(params.sessionStore, params.ref), + }); } export function recordSessionAppLogFailure(params: { - session: SessionState; - sessionName: string; + ref: SessionRef; sessionStore: SessionStore; error: unknown; backend?: LogBackend; }): ReturnType { const normalized = normalizeError(params.error); - params.sessionStore.set(params.sessionName, { - ...params.session, + const current = params.sessionStore.resolveCurrent(params.ref); + if (!current || current.appLog) return normalized; + params.sessionStore.update(params.ref, { appLog: undefined, appLogFailure: { backend: params.backend, @@ -131,12 +129,19 @@ export function recordSessionAppLogFailure(params: { } export function clearSessionAppLogFailure(params: { - session: SessionState; - sessionName: string; + ref: SessionRef; sessionStore: SessionStore; }): void { - params.sessionStore.set(params.sessionName, { - ...params.session, + params.sessionStore.update(params.ref, { appLogFailure: undefined, }); } + +export function bindSessionAppLog(sessionStore: SessionStore, ref: SessionRef) { + return bindSessionCapture(sessionStore, ref, { + read: (session) => session.appLog, + write: (appLog) => { + sessionStore.update(ref, { appLog, appLogFailure: undefined }); + }, + }); +} diff --git a/src/daemon/daemon-stop.ts b/src/daemon/daemon-stop.ts index bf5be1697b..c5e53eefcf 100644 --- a/src/daemon/daemon-stop.ts +++ b/src/daemon/daemon-stop.ts @@ -1,7 +1,6 @@ -import fs from 'node:fs'; import { AppError } from '@agent-device/kernel/errors'; -import { stopDaemonProcess } from '../daemon-process.ts'; -import { sleep } from '@agent-device/host-kit/retry'; +import type { DaemonRetirementResult } from '../daemon-registration-owner.ts'; +import type { OwnerIdentity } from '@agent-device/host-kit/process'; import type { DaemonPaths } from '../daemon-resolution.ts'; import { readRegisteredDaemonIdentity } from '../daemon-registration.ts'; @@ -9,7 +8,6 @@ import type { DeviceClaimRecord, ProviderReleaseRecord } from '../daemon-shutdow const DAEMON_STOP_GRACE_TIMEOUT_MS = 10_000; const DAEMON_STOP_KILL_TIMEOUT_MS = 2_000; -const DAEMON_STOP_METADATA_WAIT_MS = 1_000; export type DaemonStopResult = { stopped: boolean; @@ -45,25 +43,19 @@ export async function stopDaemon(params: { }): Promise { const info = readRegisteredDaemonIdentity(params.paths.infoPath); if (!info) return notRunningResult(); - const termination = await stopDaemonProcess(info, { + const { stopAndRetireDaemon } = await import('../daemon-registration-owner.ts'); + const retirement = await stopAndRetireDaemon({ + paths: params.paths, + observed: info, mode: 'graceful', termTimeoutMs: params.graceTimeoutMs ?? DAEMON_STOP_GRACE_TIMEOUT_MS, killTimeoutMs: params.killTimeoutMs ?? DAEMON_STOP_KILL_TIMEOUT_MS, }); - if (termination.status === 'retained') { - throw new AppError('COMMAND_FAILED', 'Daemon termination could not be confirmed.', { - pid: info.pid, - processStartTime: info.startTime, - reason: 'daemon_exit_unconfirmed', - terminationReason: termination.reason, - signal: termination.signal, - }); - } - if (termination.status === 'not-running' || termination.mode === 'already-exited') { + if (retirement.status === 'retained' && retirement.termination?.status === 'not-running') return notRunningResult(); - } - if (termination.mode === 'graceful') { - await waitForDaemonMetadataRemoval(params.paths, DAEMON_STOP_METADATA_WAIT_MS); + if (retirement.status !== 'retired') throw daemonRetirementError(info, retirement); + if (retirement.termination.mode === 'already-exited') return notRunningResult(); + if (retirement.termination.mode === 'graceful') { return { stopped: true, mode: 'graceful', @@ -92,6 +84,27 @@ export async function stopDaemon(params: { }; } +function daemonRetirementError( + info: OwnerIdentity, + retirement: Exclude, +): AppError { + const termination = retirement.status === 'retained' ? retirement.termination : undefined; + const failure = termination?.status === 'retained' ? termination : undefined; + const error = retirement.status === 'retained' ? retirement.error : undefined; + const { hint, diagnosticId, logPath } = error ?? {}; + return new AppError('COMMAND_FAILED', 'Daemon retirement could not be confirmed.', { + pid: info.pid, + processStartTime: info.startTime, + reason: failure ? 'daemon_exit_unconfirmed' : 'daemon_retirement_unconfirmed', + terminationReason: failure?.reason, + signal: failure?.signal, + retirement, + hint, + diagnosticId, + logPath, + }); +} + export function readDaemonStopIdentity( infoPath: string, ): { pid: number; processStartTime: string } | null { @@ -100,14 +113,6 @@ export function readDaemonStopIdentity( return { pid: info.pid, processStartTime: info.startTime }; } -async function waitForDaemonMetadataRemoval(paths: DaemonPaths, timeoutMs: number): Promise { - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - if (!fs.existsSync(paths.infoPath) && !fs.existsSync(paths.lockPath)) return; - await sleep(25); - } -} - function notRunningResult(): DaemonStopResult { return { stopped: false, diff --git a/src/daemon/device/device-claim-owner-recovery.ts b/src/daemon/device/device-claim-owner-recovery.ts index c8b60069ee..f57a6c641f 100644 --- a/src/daemon/device/device-claim-owner-recovery.ts +++ b/src/daemon/device/device-claim-owner-recovery.ts @@ -4,7 +4,11 @@ import { createPlatformRuntimeGateway } from '../../platform-runtime.ts'; import { resolveDaemonPaths } from '../../daemon-resolution.ts'; import { createDeviceClaimReconciler } from './device-claim-reconciliation.ts'; import type { DeviceClaimReconciler } from './device-claims.ts'; -import { SessionStore } from '../session-store.ts'; +import { + resolveSessionDir, + resolveSessionAppLogPath, + resolveSessionAppLogPidPath, +} from '../session-artifact-paths.ts'; export type OwnerScopedClaimRecovery = { reconcile: DeviceClaimReconciler; @@ -49,17 +53,16 @@ function composeOwnerScopedClaimRecovery( scope: PlatformRequestScope, ): OwnerScopedClaimRecovery { const daemonPaths = resolveDaemonPaths(stateDir); - const sessionStore = new SessionStore(daemonPaths.sessionsDir); const gateway = createPlatformRuntimeGateway({ sessionsDir: daemonPaths.sessionsDir, ownedProcesses: createOwnedProcessRecordStore({ stateDir: daemonPaths.baseDir, sessionsDir: daemonPaths.sessionsDir, - resolveSessionDir: (sessionId) => sessionStore.resolveSessionDir(sessionId), + resolveSessionDir: (sessionId) => resolveSessionDir(daemonPaths.sessionsDir, sessionId), }), resolveSessionArtifacts: (sessionId) => ({ - outputPath: sessionStore.resolveAppLogPath(sessionId), - pidPath: sessionStore.resolveAppLogPidPath(sessionId), + outputPath: resolveSessionAppLogPath(daemonPaths.sessionsDir, sessionId), + pidPath: resolveSessionAppLogPidPath(daemonPaths.sessionsDir, sessionId), }), }); return { diff --git a/src/daemon/generic-settle.ts b/src/daemon/generic-settle.ts index e18ca7f965..9bd5d09a63 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,9 @@ async function observeSettled( session: context.sessionName, requestId: context.req.meta?.requestId, }); - const refsGeneration = issueSettleRefs(context.session, observation); + if (context.sessionRef && !context.sessionStore.resolveCurrent(context.sessionRef)) + return undefined; + const refsGeneration = issueSettleRefs(context.sessionRef, context.sessionStore, observation); return refsGeneration === undefined ? observation : { ...observation, refsGeneration }; } @@ -120,6 +123,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/__tests__/install-source.test.ts b/src/daemon/handlers/__tests__/install-source.test.ts index ca21dfbcc4..e1243b8412 100644 --- a/src/daemon/handlers/__tests__/install-source.test.ts +++ b/src/daemon/handlers/__tests__/install-source.test.ts @@ -75,7 +75,7 @@ test('install_source materializes and deploys through one admitted runtime bindi kind: 'emulator', booted: true, }); - store.set(session.name, session); + store.publish(session.name, session); const materialize = vi.fn(async (): Promise => ({ installablePath: '/tmp/materialized/app.apk', cleanup: async () => {}, @@ -123,7 +123,7 @@ test('install_source rejects unavailable facts before binding or materializing', kind: 'emulator', booted: true, }); - store.set(session.name, session); + store.publish(session.name, session); const materialize = vi.fn(async (): Promise => ({ installablePath: '/tmp/materialized/app.apk', cleanup: async () => {}, @@ -159,7 +159,7 @@ test('install_source fails closed when a provider owner does not expose readines kind: 'emulator', booted: true, }); - store.set(session.name, session); + store.publish(session.name, session); const localMaterialization = vi.fn(async (): Promise => ({ installablePath: '/tmp/local-fallback.apk', cleanup: async () => {}, @@ -215,7 +215,7 @@ test('install_source cleans materialized paths when deployment fails after admis kind: 'emulator', booted: true, }); - store.set(session.name, session); + store.publish(session.name, session); const cleanup = vi.fn(async () => {}); const runtime = createSourceRuntime( session.device, @@ -249,7 +249,7 @@ test('install_source preserves the Android identity failure when its runtime can kind: 'emulator', booted: true, }); - store.set(session.name, session); + store.publish(session.name, session); const runtime = createSourceRuntime( session.device, async () => ({ installablePath: '/tmp/materialized/app.apk', cleanup: async () => {} }), @@ -279,7 +279,7 @@ test('install_source returns the typed iOS artifact identity supplied by its run kind: 'simulator', booted: true, }); - store.set(session.name, session); + store.publish(session.name, session); const runtime = createSourceRuntime( session.device, async () => ({ @@ -325,7 +325,7 @@ test('install_source accepts the public leaf selector of an Apple session it is kind: 'simulator', booted: true, }); - store.set(session.name, session); + store.publish(session.name, session); const runtime = createSourceRuntime( session.device, async () => ({ @@ -358,7 +358,7 @@ test('install_source still refuses a leaf selector that names a different platfo kind: 'simulator', booted: true, }); - store.set(session.name, session); + store.publish(session.name, session); const runtime = createSourceRuntime( session.device, async () => ({ installablePath: '/tmp/App.app', cleanup: async () => {} }), diff --git a/src/daemon/handlers/__tests__/react-native.test.ts b/src/daemon/handlers/__tests__/react-native.test.ts index 68dbd0853f..7d5b49252a 100644 --- a/src/daemon/handlers/__tests__/react-native.test.ts +++ b/src/daemon/handlers/__tests__/react-native.test.ts @@ -32,7 +32,7 @@ beforeEach(() => { test('react-native dismiss-overlay taps collapsed warning close affordance instead of banner center', async () => { const sessionName = 'rn-session'; const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); mockCaptureSnapshot .mockResolvedValueOnce({ snapshot: { @@ -97,7 +97,7 @@ test('react-native dismiss-overlay taps collapsed warning close affordance inste test('react-native dismiss-overlay prefers non-trailing collapsed warning close controls', async () => { const sessionName = 'rn-session'; const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); mockCaptureSnapshot.mockResolvedValue({ snapshot: { nodes: [ @@ -151,7 +151,7 @@ test('react-native dismiss-overlay prefers non-trailing collapsed warning close test('react-native dismiss-overlay does not confuse app dismiss buttons with overlay controls', async () => { const sessionName = 'rn-collapsed-with-app-dismiss-session'; const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); mockCaptureSnapshot .mockResolvedValueOnce({ snapshot: { @@ -222,7 +222,7 @@ test('react-native dismiss-overlay does not confuse app dismiss buttons with ove test('react-native dismiss-overlay rejects unsafe collapsed warning coordinate fallback', async () => { const sessionName = 'rn-session'; const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); mockCaptureSnapshot.mockResolvedValue({ snapshot: { nodes: [ @@ -266,7 +266,7 @@ test('react-native dismiss-overlay rejects unsafe collapsed warning coordinate f test('react-native dismiss-overlay dismisses RedBox error overlays instead of minimizing them', async () => { const sessionName = 'rn-redbox-session'; const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); mockCaptureSnapshot .mockResolvedValueOnce({ snapshot: { @@ -336,7 +336,7 @@ test('react-native dismiss-overlay dismisses RedBox error overlays instead of mi test('react-native dismiss-overlay reports unverified dismiss when RedBox controls remain', async () => { const sessionName = 'rn-redbox-still-full-session'; const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); const fullRedBoxSnapshot = { snapshot: { nodes: [ @@ -397,7 +397,7 @@ test('react-native dismiss-overlay reports unverified dismiss when RedBox contro test('react-native dismiss-overlay uses Dismiss when RedBox Minimize is absent', async () => { const sessionName = 'rn-redbox-dismiss-session'; const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); mockCaptureSnapshot.mockResolvedValue({ snapshot: { nodes: [ @@ -448,7 +448,7 @@ test('react-native dismiss-overlay uses Dismiss when RedBox Minimize is absent', test('react-native dismiss-overlay accepts RedBox control labels with keyboard shortcut suffixes', async () => { const sessionName = 'rn-redbox-shortcut-session'; const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); mockCaptureSnapshot.mockResolvedValue({ snapshot: { nodes: [ @@ -499,7 +499,7 @@ test('react-native dismiss-overlay accepts RedBox control labels with keyboard s test('react-native dismiss-overlay prefers concrete RedBox buttons over labeled wrappers', async () => { const sessionName = 'rn-redbox-wrapper-session'; const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); mockCaptureSnapshot.mockResolvedValue({ snapshot: { nodes: [ @@ -556,7 +556,7 @@ test('react-native dismiss-overlay prefers concrete RedBox buttons over labeled test('react-native dismiss-overlay reports verified success after a clean post-dismiss snapshot', async () => { const sessionName = 'rn-verify-session'; const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, makeSession(sessionName, 'android')); + sessionStore.publish(sessionName, makeSession(sessionName, 'android')); mockCaptureSnapshot .mockResolvedValueOnce({ snapshot: { @@ -635,7 +635,7 @@ test('react-native dismiss-overlay reports sparse verdict instead of no overlay createdAt: Date.now(), }; const previousSnapshot = session.snapshot; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockCaptureSnapshot.mockResolvedValue({ snapshot: { nodes: [ @@ -687,7 +687,7 @@ test('react-native dismiss-overlay reports sparse verdict instead of no overlay test('react-native dismiss-overlay reports unverified dismiss when post-dismiss snapshot is sparse', async () => { const sessionName = 'rn-verify-sparse-session'; const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); mockCaptureSnapshot .mockResolvedValueOnce({ snapshot: { @@ -755,7 +755,7 @@ test('react-native dismiss-overlay reports unverified dismiss when post-dismiss test('react-native dismiss-overlay reports still-visible overlays with recovery guidance', async () => { const sessionName = 'rn-verify-still-visible-session'; const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, makeSession(sessionName, 'android')); + sessionStore.publish(sessionName, makeSession(sessionName, 'android')); const overlaySnapshot = { snapshot: { nodes: [ @@ -805,7 +805,7 @@ test('react-native dismiss-overlay reports still-visible overlays with recovery test('react-native dismiss-overlay ignores app copy that only mentions RN overlay terms', async () => { const sessionName = 'rn-copy-session'; const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); mockCaptureSnapshot.mockResolvedValue({ snapshot: { nodes: [ diff --git a/src/daemon/handlers/__tests__/record-runtime-stop-recovery.test.ts b/src/daemon/handlers/__tests__/record-runtime-stop-recovery.test.ts index 539546bd48..96ad8c41a3 100644 --- a/src/daemon/handlers/__tests__/record-runtime-stop-recovery.test.ts +++ b/src/daemon/handlers/__tests__/record-runtime-stop-recovery.test.ts @@ -65,9 +65,9 @@ test('record stop after daemon-state loss reattaches only through the persisted 'capture.mp4', ); await harness.run(['start', outPath]); - const adopted = harness.sessionStore.get(harness.sessionName); - if (!adopted) throw new Error('Expected adopted recording session'); - harness.sessionStore.set(harness.sessionName, { ...adopted, screenRecording: undefined }); + const ref = harness.sessionStore.lookup(harness.sessionName); + if (!ref) throw new Error('Expected adopted recording session'); + harness.sessionStore.update(ref, { screenRecording: undefined }); const stopped = await harness.run(['stop']); @@ -87,9 +87,9 @@ test('record stop terminalizes cleanup-only exact recovery without starting a re 'capture.mp4', ); await harness.run(['start', outPath]); - const adopted = harness.sessionStore.get(harness.sessionName); - if (!adopted) throw new Error('Expected adopted recording session'); - harness.sessionStore.set(harness.sessionName, { ...adopted, screenRecording: undefined }); + const ref = harness.sessionStore.lookup(harness.sessionName); + if (!ref) throw new Error('Expected adopted recording session'); + harness.sessionStore.update(ref, { screenRecording: undefined }); const stopped = await harness.run(['stop']); @@ -120,9 +120,9 @@ test('record stop rejects a cross-session recovery manifest before exact-owner b ...record.envelope, sessionId: 'recording-b', }); - const adopted = harness.sessionStore.get(harness.sessionName); - if (!adopted) throw new Error('Expected adopted recording session'); - harness.sessionStore.set(harness.sessionName, { ...adopted, screenRecording: undefined }); + const ref = harness.sessionStore.lookup(harness.sessionName); + if (!ref) throw new Error('Expected adopted recording session'); + harness.sessionStore.update(ref, { screenRecording: undefined }); const stopped = await harness.run(['stop']); diff --git a/src/daemon/handlers/__tests__/record-runtime.fixtures.ts b/src/daemon/handlers/__tests__/record-runtime.fixtures.ts index ebab2a2433..d7afd8e640 100644 --- a/src/daemon/handlers/__tests__/record-runtime.fixtures.ts +++ b/src/daemon/handlers/__tests__/record-runtime.fixtures.ts @@ -53,7 +53,7 @@ export function makeRecordRuntimeHarness( createdAt: 1, actions: [], }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const runtime = makeRuntime(session, options.runtime); const common = { sessionName, diff --git a/src/daemon/handlers/__tests__/session-appstate-input.test.ts b/src/daemon/handlers/__tests__/session-appstate-input.test.ts index bf1a1327e6..308f84dcc8 100644 --- a/src/daemon/handlers/__tests__/session-appstate-input.test.ts +++ b/src/daemon/handlers/__tests__/session-appstate-input.test.ts @@ -32,7 +32,7 @@ import type { BindDeviceRuntime } from '../../request-runtime-binding.ts'; test('appstate on iOS requires active session on selected device', async () => { const sessionStore = makeSessionStore(); const sessionName = 'default'; - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, { platform: 'apple', id: 'sim-1', @@ -78,7 +78,7 @@ test('appstate on iOS requires active session on selected device', async () => { test('appstate returns session appName when bundle id is unavailable', async () => { const sessionStore = makeSessionStore(); const sessionName = 'sim'; - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, { platform: 'apple', id: 'sim-1', @@ -128,7 +128,7 @@ test('appstate returns session appName when bundle id is unavailable', async () test('appstate fails when iOS session has no tracked app', async () => { const sessionStore = makeSessionStore(); const sessionName = 'sim'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { platform: 'apple', @@ -256,7 +256,7 @@ test('clipboard requires an active session or explicit device selector', async ( test('clipboard rejects unsupported iOS physical devices', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-device-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { platform: 'apple', @@ -342,7 +342,7 @@ function iosSessionRequest(sessionName: string): DaemonRequest { test('appstate on iOS reads the session app state from the runner when its owner admits it', async () => { const sessionStore = makeSessionStore(); const sessionName = 'sim'; - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, IOS_SESSION_DEVICE), appBundleId: 'dev.e2e.benchmark', appName: 'Benchmark', @@ -377,7 +377,7 @@ test('appstate on iOS reads the session app state from the runner when its owner test('appstate on iOS keeps the session answer, with no state, when no runner is live to ask', async () => { const sessionStore = makeSessionStore(); const sessionName = 'sim'; - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, IOS_SESSION_DEVICE), appBundleId: 'dev.e2e.benchmark', appName: 'Benchmark', @@ -404,7 +404,7 @@ test('appstate on iOS keeps the session answer, with no state, when no runner is test('appstate on iOS keeps the session answer, with no state, when the runner cannot read one', async () => { const sessionStore = makeSessionStore(); const sessionName = 'sim'; - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, IOS_SESSION_DEVICE), appBundleId: 'dev.e2e.benchmark', appName: 'Benchmark', diff --git a/src/daemon/handlers/__tests__/session-boot-shutdown.test.ts b/src/daemon/handlers/__tests__/session-boot-shutdown.test.ts index a325dedd7c..a9b87ae488 100644 --- a/src/daemon/handlers/__tests__/session-boot-shutdown.test.ts +++ b/src/daemon/handlers/__tests__/session-boot-shutdown.test.ts @@ -44,7 +44,7 @@ test('boot requires session or explicit selector', async () => { test('boot prefers explicit device selector over active session device', async () => { const sessionStore = makeSessionStore(); const sessionName = 'default'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { platform: 'android', @@ -473,7 +473,7 @@ test('shutdown rejects active session device and points to close --shutdown', as target: 'mobile', booted: true, }; - sessionStore.set(sessionName, makeSession(sessionName, selectedDevice)); + sessionStore.publish(sessionName, makeSession(sessionName, selectedDevice)); mockResolveTargetDevice.mockResolvedValue(selectedDevice); const response = await handleSessionCommands({ diff --git a/src/daemon/handlers/__tests__/session-clipboard.test.ts b/src/daemon/handlers/__tests__/session-clipboard.test.ts index 8b99e82bb4..fd82f24675 100644 --- a/src/daemon/handlers/__tests__/session-clipboard.test.ts +++ b/src/daemon/handlers/__tests__/session-clipboard.test.ts @@ -73,7 +73,7 @@ function harness( function request(positionals: string[]) { const sessionName = 'clipboard-session'; const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, makeSession(sessionName, androidDevice)); + sessionStore.publish(sessionName, makeSession(sessionName, androidDevice)); mockResolveTargetDevice.mockResolvedValue(androidDevice); return { sessionName, @@ -216,3 +216,28 @@ test('clipboard write with no text argument reports how to clear instead', async 'clipboard write requires text (use "" to clear clipboard)', ); }); + +for (const action of ['read', 'write'] as const) { + test(`clipboard ${action} refuses retirement during device resolution before binding`, async () => { + const spies = harness({ read: available, write: available }); + const input = request(action === 'read' ? ['read'] : ['write', 'text']); + const ref = input.sessionStore.lookup(input.sessionName)!; + mockResolveTargetDevice.mockImplementationOnce(async () => { + input.sessionStore.retire(ref); + input.sessionStore.publish(input.sessionName, makeSession(input.sessionName, androidDevice)); + return androidDevice; + }); + await expect( + handleSessionClipboardCommand({ + ...input, + req: { ...input.req, flags: { platform: 'android', serial: androidDevice.id } }, + ...spies, + }), + ).rejects.toMatchObject({ details: { reason: 'session_lifetime_ended' } }); + expect(spies.inspectFacts).not.toHaveBeenCalled(); + expect(spies.bindDevice).not.toHaveBeenCalled(); + expect(mockEnsureDeviceReady).not.toHaveBeenCalled(); + expect(spies.readClipboard).not.toHaveBeenCalled(); + expect(spies.writeClipboard).not.toHaveBeenCalled(); + }); +} diff --git a/src/daemon/handlers/__tests__/session-device-claims.test.ts b/src/daemon/handlers/__tests__/session-device-claims.test.ts index d45941b2c1..8ba2fa6bb8 100644 --- a/src/daemon/handlers/__tests__/session-device-claims.test.ts +++ b/src/daemon/handlers/__tests__/session-device-claims.test.ts @@ -75,7 +75,6 @@ const mockResolveTargetDevice = vi.mocked(resolveTargetDevice); const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); const mockApplyRuntimeHints = vi.mocked(applyRuntimeHintValues); const mockResolveAndroidPackage = vi.mocked(resolveAndroidPackageForOpen); -const roots: string[] = []; const reconcileOrphanedDeviceClaim = async () => ({ status: 'retained' as const, reason: 'test-no-recovery', @@ -115,14 +114,12 @@ afterEach(() => { mockApplyRuntimeHints.mockResolvedValue(undefined); mockResolveAndroidPackage.mockResolvedValue(undefined); delete process.env.AGENT_DEVICE_CLAIMS_DIR; - for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); }); function setup(): { store: SessionStore; stateDir: string } { const stateDir = mkdtempForTestSync('agent-device-session-device-claim-'); const claimsDir = path.join(stateDir, 'claims'); process.env.AGENT_DEVICE_CLAIMS_DIR = claimsDir; - roots.push(stateDir); return { store: new SessionStore(path.join(stateDir, 'sessions')), stateDir }; } @@ -452,7 +449,7 @@ test('local close clears its matching device claim after teardown', async () => }); assert.equal(acquired.status, 'acquired'); if (acquired.status !== 'acquired') return; - store.set('close-claim', { + store.publish('close-claim', { name: 'close-claim', device: android, deviceClaim: acquired.ownership, @@ -497,7 +494,7 @@ test('#1391: a close-time script save failure still clears the device claim and target: { kind: 'explicit', path: targetPath, force: false }, }, }); - store.set('close-save-script-failure', session); + store.publish('close-save-script-failure', session); mockDispatch.mockResolvedValue(undefined); // Like the platform-close-error tests above, a failed close-time save is diff --git a/src/daemon/handlers/__tests__/session-doctor-app-runtime.test.ts b/src/daemon/handlers/__tests__/session-doctor-app-runtime.test.ts index 5cd51d1ad4..84394ce3d2 100644 --- a/src/daemon/handlers/__tests__/session-doctor-app-runtime.test.ts +++ b/src/daemon/handlers/__tests__/session-doctor-app-runtime.test.ts @@ -31,7 +31,7 @@ vi.mock('../session-doctor-metro.ts', () => ({ test('doctor app checks retain runtime admission failures as a failed target-app check', async () => { const sessionName = 'doctor-app-runtime-failure'; const sessionStore = makeSessionStore('agent-device-doctor-app-runtime-'); - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { name: sessionName, device: LINUX_DEVICE, createdAt: Date.now(), @@ -135,7 +135,7 @@ test('doctor preserves the legacy unsupported HarmonyOS target-app check', async }; const sessionName = 'doctor-harmony-app-runtime'; const sessionStore = makeSessionStore('agent-device-doctor-harmony-'); - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { name: sessionName, device: harmonyDevice, createdAt: Date.now(), diff --git a/src/daemon/handlers/__tests__/session-doctor-options.test.ts b/src/daemon/handlers/__tests__/session-doctor-options.test.ts index 683584b204..bb575d87a2 100644 --- a/src/daemon/handlers/__tests__/session-doctor-options.test.ts +++ b/src/daemon/handlers/__tests__/session-doctor-options.test.ts @@ -11,8 +11,8 @@ test('the same-device warning names the other session by its address, not its na const store = makeSessionStore(); const here = 'cwd:aaaaaaaaaaaaaaaa:default'; const other = 'cwd:bbbbbbbbbbbbbbbb:default'; - store.set(here, makeIosSession('default')); - store.set(other, makeIosSession('default')); + store.publish(here, makeIosSession('default')); + store.publish(other, makeIosSession('default')); const [check] = sessionChecks(store, here, store.get(here)); @@ -26,7 +26,7 @@ test('the same-device warning names the other session by its address, not its na test('a session alone on its device passes with its own address in evidence', () => { const store = makeSessionStore(); const here = 'cwd:aaaaaaaaaaaaaaaa:default'; - store.set(here, makeIosSession('default')); + store.publish(here, makeIosSession('default')); const [check] = sessionChecks(store, here, store.get(here)); diff --git a/src/daemon/handlers/__tests__/session-doctor-warmup.test.ts b/src/daemon/handlers/__tests__/session-doctor-warmup.test.ts index 2adb0e5820..dad880c522 100644 --- a/src/daemon/handlers/__tests__/session-doctor-warmup.test.ts +++ b/src/daemon/handlers/__tests__/session-doctor-warmup.test.ts @@ -62,7 +62,7 @@ beforeEach(() => { async function runDoctorWithSessionDevice(device: DeviceInfo): Promise { const sessionStore = makeSessionStore('agent-device-doctor-warmup-'); - sessionStore.set('doctor-session', { + sessionStore.publish('doctor-session', { name: 'doctor-session', createdAt: Date.now(), device, diff --git a/src/daemon/handlers/__tests__/session-install-source-ref-frame.test.ts b/src/daemon/handlers/__tests__/session-install-source-ref-frame.test.ts index 81a4a9cd2d..fc0fcfc6ea 100644 --- a/src/daemon/handlers/__tests__/session-install-source-ref-frame.test.ts +++ b/src/daemon/handlers/__tests__/session-install-source-ref-frame.test.ts @@ -19,7 +19,7 @@ const invoke = async (): Promise => { test('install_source admission failure preserves an active ref frame', async () => { const sessionStore = makeSessionStore('agent-device-session-install-source-ref-admission-'); const session = activeSession(); - sessionStore.set('default', session); + sessionStore.publish('default', session); const inspectFacts = vi.fn(async (device) => { const facts = await mockInspectDeviceRuntimeFacts(device); return { @@ -46,7 +46,7 @@ test('install_source admission failure preserves an active ref frame', async () test('install_source materialization failure preserves an active ref frame', async () => { const sessionStore = makeSessionStore('agent-device-session-install-source-ref-materialization-'); const session = activeSession(); - sessionStore.set('default', session); + sessionStore.publish('default', session); mockMaterializeAppSourceRuntime.mockRejectedValueOnce(new Error('artifact download failed')); const response = await dispatchInstallSource({ sessionStore }); @@ -63,7 +63,7 @@ test('install_source materialization failure preserves an active ref frame', asy test('install_source deploy attempt expires an active ref frame when the bound operation fails', async () => { const sessionStore = makeSessionStore('agent-device-session-install-source-ref-deploy-'); const session = activeSession(); - sessionStore.set('default', session); + sessionStore.publish('default', session); mockDeployMaterializedAppRuntime.mockImplementationOnce(async () => { expect(refFrameState(session)).toBe('expired'); expect(session.snapshotScopeSource).toBeUndefined(); diff --git a/src/daemon/handlers/__tests__/session-prepare.test.ts b/src/daemon/handlers/__tests__/session-prepare.test.ts index 5804f8e8b5..2906f6442f 100644 --- a/src/daemon/handlers/__tests__/session-prepare.test.ts +++ b/src/daemon/handlers/__tests__/session-prepare.test.ts @@ -52,7 +52,7 @@ async function runPrepare(flags: { timeoutMs?: number }): Promise<{ }> { const sessionName = 'prepare-envelope-margin'; const sessionStore = makeSessionStore('agent-device-prepare-handler-'); - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { name: sessionName, device: IOS_SIMULATOR, createdAt: Date.now(), diff --git a/src/daemon/handlers/__tests__/session-push-ref-frame.test.ts b/src/daemon/handlers/__tests__/session-push-ref-frame.test.ts index f8576d1661..97409a3a41 100644 --- a/src/daemon/handlers/__tests__/session-push-ref-frame.test.ts +++ b/src/daemon/handlers/__tests__/session-push-ref-frame.test.ts @@ -18,7 +18,7 @@ const invoke = async (): Promise => { test('push admission failure preserves an active ref frame', async () => { const sessionStore = makeSessionStore('agent-device-session-push-ref-admission-'); const session = activeSession(); - sessionStore.set('default', session); + sessionStore.publish('default', session); const inspectFacts = vi.fn(async (device) => { const facts = await mockInspectDeviceRuntimeFacts(device); return { @@ -45,7 +45,7 @@ test('push admission failure preserves an active ref frame', async () => { test('push dispatch attempt expires an active ref frame when the bound operation fails', async () => { const sessionStore = makeSessionStore('agent-device-session-push-ref-dispatch-'); const session = activeSession(); - sessionStore.set('default', session); + sessionStore.publish('default', session); mockPushNotificationRuntime.mockRejectedValueOnce(new Error('provider dispatch failed')); await expect(dispatchPush({ sessionStore })).rejects.toThrow('provider dispatch failed'); diff --git a/src/daemon/handlers/__tests__/session-push.test.ts b/src/daemon/handlers/__tests__/session-push.test.ts index 39ef7e957f..12649806cc 100644 --- a/src/daemon/handlers/__tests__/session-push.test.ts +++ b/src/daemon/handlers/__tests__/session-push.test.ts @@ -49,7 +49,7 @@ test('push requires active session or explicit device selector', async () => { test('push validates payload before runtime facts admission', async () => { const sessionStore = makeSessionStore('agent-device-session-push-invalid-'); - sessionStore.set('default', { + sessionStore.publish('default', { name: 'default', createdAt: Date.now(), actions: [], @@ -83,7 +83,7 @@ test('push validates payload before runtime facts admission', async () => { test('push runs readiness and notification through one admitted runtime binding', async () => { const sessionStore = makeSessionStore('agent-device-session-push-runtime-'); - sessionStore.set('default', { + sessionStore.publish('default', { name: 'default', createdAt: Date.now(), actions: [], @@ -141,7 +141,7 @@ test('push runs readiness and notification through one admitted runtime binding' test('push treats an existing brace-prefixed payload as a file before inline JSON parsing', async () => { const sessionStore = makeSessionStore('agent-device-session-push-payload-file-'); - sessionStore.set('default', { + sessionStore.publish('default', { name: 'default', createdAt: Date.now(), actions: [], @@ -192,7 +192,7 @@ test('push treats an existing brace-prefixed payload as a file before inline JSO test('push stops at unavailable facts without binding', async () => { const sessionStore = makeSessionStore('agent-device-session-push-unsupported-'); - sessionStore.set('default', { + sessionStore.publish('default', { name: 'default', createdAt: Date.now(), actions: [], @@ -229,7 +229,7 @@ test('push stops at unavailable facts without binding', async () => { test('push fails closed before binding when a provider owner lacks readiness', async () => { const sessionStore = makeSessionStore('agent-device-session-push-provider-readiness-'); - sessionStore.set('default', { + sessionStore.publish('default', { name: 'default', createdAt: Date.now(), actions: [], diff --git a/src/daemon/handlers/__tests__/session-reinstall.test.ts b/src/daemon/handlers/__tests__/session-reinstall.test.ts index a48adcfc7e..c255a49ad0 100644 --- a/src/daemon/handlers/__tests__/session-reinstall.test.ts +++ b/src/daemon/handlers/__tests__/session-reinstall.test.ts @@ -70,7 +70,7 @@ test('reinstall requires active session or explicit device selector', async () = test('reinstall validates required args before runtime admission', async () => { const store = makeStore(); - store.set( + store.publish( 'default', makeSession('default', { platform: 'apple', @@ -108,7 +108,7 @@ test('install binds the deployment use exactly once after one facts admission', kind: 'emulator', booted: true, }); - store.set(session.name, session); + store.publish(session.name, session); const root = mkdtempForTestSync('agent-device-install-runtime-'); const appPath = path.join(root, 'Sample.apk'); fs.writeFileSync(appPath, 'placeholder'); @@ -157,7 +157,7 @@ test('install fails closed before binding when its provider owner lacks deployme kind: 'device', booted: true, }); - store.set(session.name, session); + store.publish(session.name, session); const root = mkdtempForTestSync('agent-device-install-provider-unavailable-'); const appPath = path.join(root, 'Sample.apk'); fs.writeFileSync(appPath, 'placeholder'); @@ -216,7 +216,7 @@ test('reinstall cleans an uploaded artifact after the request-scoped operation', kind: 'simulator', booted: true, }); - store.set(session.name, session); + store.publish(session.name, session); const root = mkdtempForTestSync('agent-device-uploaded-artifact-'); const appPath = path.join(root, 'Sample.app'); fs.writeFileSync(appPath, 'placeholder'); @@ -252,7 +252,7 @@ test('HarmonyOS reinstall updates the active session identity from the runtime r kind: 'emulator', booted: true, }); - store.set(session.name, session); + store.publish(session.name, session); const root = mkdtempForTestSync('agent-device-harmony-reinstall-'); const appPath = path.join(root, 'Sample.hap'); fs.writeFileSync(appPath, 'placeholder'); @@ -278,3 +278,73 @@ test('HarmonyOS reinstall updates the active session identity from the runtime r appName: 'com.example.application', }); }); + +test.each(['rebuild', 'retire'] as const)( + 'held HarmonyOS deployment patches only its admitted lifetime across %s', + async (transition) => { + const store = makeStore(); + const address = 'cwd:held-harmony-deploy:default'; + const session = makeSession('default', { + platform: 'harmonyos', + id: '127.0.0.1:5555', + name: 'Emulator', + kind: 'emulator', + booted: true, + }); + const ref = store.publish(address, session); + const appPath = path.join(mkdtempForTestSync('held-deploy-'), 'Sample.hap'); + fs.writeFileSync(appPath, 'placeholder'); + let start!: () => void; + let release!: () => void; + const started = new Promise((resolve) => { + start = resolve; + }); + const released = new Promise((resolve) => { + release = resolve; + }); + mockDeployAppRuntime.mockImplementationOnce(async () => { + start(); + await released; + return { packageName: 'com.example.updated' }; + }); + const running = handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'reinstall', + positionals: ['com.example.updated', appPath], + flags: {}, + }, + sessionName: address, + sessionStore: store, + logPath: '/tmp/daemon.log', + invoke, + }); + await started; + let current = session; + if (transition === 'rebuild') + current = store.update(ref, { + actions: [], + trace: { outPath: '/latest.trace', startedAt: 1 }, + }); + else { + store.retire(ref); + current = store.publish(address, makeSession('default', session.device)).session; + } + release(); + if (transition === 'rebuild') { + expect(await running).toMatchObject({ ok: true }); + expect(store.requireCurrent(ref).trace).toEqual(current.trace); + expect(store.requireCurrent(ref).appBundleId).toBe('com.example.updated'); + expect(current.actions.map((action) => action.command)).toEqual(['reinstall']); + expect(session.actions).toEqual([]); + } else { + await expect(running).rejects.toMatchObject({ + details: { reason: 'session_lifetime_ended' }, + }); + expect(store.get(address)).toBe(current); + expect(current.appBundleId).toBeUndefined(); + expect(current.actions).toEqual([]); + } + }, +); diff --git a/src/daemon/handlers/__tests__/session-relaunch-close.test.ts b/src/daemon/handlers/__tests__/session-relaunch-close.test.ts index 0ee75ae19a..9af06a3e6f 100644 --- a/src/daemon/handlers/__tests__/session-relaunch-close.test.ts +++ b/src/daemon/handlers/__tests__/session-relaunch-close.test.ts @@ -157,7 +157,7 @@ function sessionRequest( test('open --relaunch closes and reopens active session app', async () => { const sessionStore = makeSessionStore('agent-device-relaunch-close-'); const sessionName = 'android-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { @@ -191,7 +191,7 @@ test('open --relaunch closes and reopens active session app', async () => { test('open --relaunch leaves the old frame expired when the close dispatch fails after dispatch (ADR 0014)', async () => { const sessionStore = makeSessionStore('agent-device-relaunch-close-'); const sessionName = 'android-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { @@ -283,7 +283,10 @@ test('open --relaunch on physical iOS retains runner through close/open', async kind: 'device' as const, booted: true, }; - sessionStore.set(sessionName, makeSession(sessionName, { device, appName: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeSession(sessionName, { device, appName: 'com.example.app' }), + ); const calls: string[] = []; mockResolveTargetDevice.mockResolvedValue(device); @@ -317,7 +320,10 @@ test('open --relaunch on iOS simulator collapses into one terminate-running open kind: 'simulator' as const, booted: true, }; - sessionStore.set(sessionName, makeSession(sessionName, { device, appName: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeSession(sessionName, { device, appName: 'com.example.app' }), + ); const calls: string[] = []; mockResolveTargetDevice.mockResolvedValue(device); @@ -354,7 +360,10 @@ test('open --relaunch on iOS simulator delegates termination to the kind: 'simulator' as const, booted: true, }; - sessionStore.set(sessionName, makeSession(sessionName, { device, appName: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeSession(sessionName, { device, appName: 'com.example.app' }), + ); const calls: string[] = []; mockResolveTargetDevice.mockResolvedValue(device); @@ -387,7 +396,10 @@ test('open --relaunch --clear-app-state on iOS simulator keeps close-first order kind: 'simulator' as const, booted: true, }; - sessionStore.set(sessionName, makeSession(sessionName, { device, appName: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeSession(sessionName, { device, appName: 'com.example.app' }), + ); const calls: string[] = []; mockResolveTargetDevice.mockResolvedValue(device); @@ -420,7 +432,7 @@ test('open --relaunch includes timing and waits for iOS runner prewarm after ope kind: 'device' as const, booted: true, }; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device, appName: 'Example', appBundleId: 'com.example.app' }), ); @@ -501,7 +513,7 @@ test('open --relaunch on iOS without existing session closes then opens target a test('close on macOS session stops runner and dismisses automation alert before delete', async () => { const sessionStore = makeSessionStore('agent-device-relaunch-close-'); const sessionName = 'macos-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { @@ -542,7 +554,7 @@ test('close on macOS session stops runner and dismisses automation alert before test('close on iOS simulator session retains runner and deletes the session', async () => { const sessionStore = makeSessionStore('agent-device-relaunch-close-'); const sessionName = 'ios-simulator-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { @@ -567,7 +579,7 @@ test('close on iOS simulator session retains runner and deletes the session', as test('close on iOS simulator with scoped simulator set retains runner and deletes the session', async () => { const sessionStore = makeSessionStore('agent-device-relaunch-close-'); const sessionName = 'ios-scoped-simulator-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { @@ -600,7 +612,7 @@ test('close on leased iOS simulator session stops runner before deleting session deviceKey: 'ios:sim-1', clientId: 'client-a', }); - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { @@ -636,7 +648,7 @@ test('close on leased iOS simulator session stops runner before deleting session test('close --shutdown on iOS simulator stops runner before deleting session', async () => { const sessionStore = makeSessionStore('agent-device-relaunch-close-'); const sessionName = 'ios-simulator-shutdown-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { @@ -662,7 +674,7 @@ test('close --shutdown on iOS simulator stops runner before deleting session', a test('close on iOS stops runner before app close dispatch and performs final idempotent stop', async () => { const sessionStore = makeSessionStore('agent-device-relaunch-close-'); const sessionName = 'ios-close-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { @@ -699,7 +711,7 @@ test('close on iOS stops runner before app close dispatch and performs fin test('close on iOS simulator retains runner while terminating app', async () => { const sessionStore = makeSessionStore('agent-device-relaunch-close-'); const sessionName = 'ios-simulator-close-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { @@ -736,7 +748,7 @@ test('close on iOS simulator retains runner while terminating app', async test('app-only close terminates an iOS simulator app without ending its session', async () => { const sessionStore = makeSessionStore('agent-device-relaunch-close-'); const sessionName = 'ios-simulator-app-only-close'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { @@ -775,7 +787,7 @@ test('app-only close terminates an iOS simulator app without ending its session' test('close on macOS stops runner before app close dispatch and dismisses automation alert', async () => { const sessionStore = makeSessionStore('agent-device-relaunch-close-'); const sessionName = 'macos-close-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { diff --git a/src/daemon/handlers/__tests__/session-relaunch-guards.test.ts b/src/daemon/handlers/__tests__/session-relaunch-guards.test.ts index e4ac9574d0..c36126c636 100644 --- a/src/daemon/handlers/__tests__/session-relaunch-guards.test.ts +++ b/src/daemon/handlers/__tests__/session-relaunch-guards.test.ts @@ -115,7 +115,7 @@ test('open --relaunch rejects Android app binary paths for active sessions', asy }); session.appName = 'com.example.app'; session.appBundleId = 'com.example.app'; - sessionStore.set('default', session); + sessionStore.publish('default', session); const response = await handleSessionCommands({ req: { @@ -162,7 +162,7 @@ test('open --relaunch rejects Android app binary paths before resolving a new de test('open on in-use device returns DEVICE_IN_USE before readiness checks', async () => { const sessionStore = makeSessionStore(); - sessionStore.set( + sessionStore.publish( 'busy-session', makeSession('busy-session', { platform: 'apple', @@ -225,7 +225,7 @@ test('open on device owned by recording session returns recording recovery hint' showTouches: false, recordOnlySession: true, }); - sessionStore.set('default', recordingSession); + sessionStore.publish('default', recordingSession); mockResolveTargetDevice.mockResolvedValue({ platform: 'apple', diff --git a/src/daemon/handlers/__tests__/session-runtime-command.test.ts b/src/daemon/handlers/__tests__/session-runtime-command.test.ts index 83690a8b3a..c9c8dfc034 100644 --- a/src/daemon/handlers/__tests__/session-runtime-command.test.ts +++ b/src/daemon/handlers/__tests__/session-runtime-command.test.ts @@ -96,7 +96,7 @@ test('runtime clear removes applied transport hints for the active app', async ( metroHost: '10.0.0.10', metroPort: 8081, }); - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, { platform: 'android', id: 'emulator-5554', @@ -146,7 +146,7 @@ test('runtime clear expires the ref frame at the admitted hint mutation boundary }), appBundleId: 'com.example.demo', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); sessionStore.setRuntimeHints(sessionName, { platform: 'android', metroHost: '10.0.2.2', @@ -190,7 +190,7 @@ test('runtime clear rejects a false runtime-hints fact before its one implementa metroHost: '10.0.0.10', metroPort: 8081, }); - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, device), appBundleId: 'com.example.demo', }); @@ -247,7 +247,7 @@ test('runtime gesture-viewport admits and binds the exact viewport operation onc mkdtempForTestSync('runtime-gesture-viewport'), 'runtime-gesture-viewport.log', ); - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { platform: 'android', @@ -290,3 +290,101 @@ test('runtime gesture-viewport admits and binds the exact viewport operation onc }), ); }); + +for (const phase of ['admission', 'effect'] as const) { + test(`runtime clear does not clear a successor's hints after retirement during ${phase}`, async () => { + const sessionStore = makeSessionStore(); + const address = `cwd:runtime-clear-${phase}:default`; + const device = { + platform: 'android' as const, + id: `runtime-clear-${phase}`, + name: 'Pixel', + kind: 'emulator' as const, + booted: true, + }; + const ref = sessionStore.publish(address, { + ...makeSession('default', device), + appBundleId: 'com.example.old', + }); + sessionStore.setRuntimeHints(address, { platform: 'android', metroHost: 'old' }); + const successorHints = { platform: 'android' as const, metroHost: 'successor' }; + const replace = () => { + sessionStore.retire(ref); + sessionStore.publish(address, { + ...makeSession('default', device), + appBundleId: 'com.example.new', + }); + sessionStore.setRuntimeHints(address, successorHints); + }; + if (phase === 'admission') { + mockInspectDeviceRuntimeFacts.mockImplementationOnce(async (target) => { + replace(); + return lifecycleRuntimeFacts(target); + }); + } else { + mockClearRuntimeHints.mockImplementationOnce(async () => { + replace(); + }); + } + await expect( + handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'runtime', + positionals: ['clear'], + flags: {}, + }, + sessionName: address, + logPath: '/dev/null', + sessionStore, + invoke: noopInvoke, + }), + ).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { reason: 'session_lifetime_ended' }, + }); + expect(sessionStore.getRuntimeHints(address)).toBe(successorHints); + expect(refFrameState(sessionStore.get(address)!)).toBe('active'); + expect(mockClearRuntimeHints).toHaveBeenCalledTimes(phase === 'admission' ? 0 : 1); + }); +} + +test('runtime clear expires and uses the latest matching record after admission', async () => { + const sessionStore = makeSessionStore(); + const device = { + platform: 'android' as const, + id: 'runtime-clear-rebuild', + name: 'Pixel', + kind: 'emulator' as const, + booted: true, + }; + const ref = sessionStore.publish('clear-rebuild', { + ...makeSession('clear-rebuild', device), + appBundleId: 'old.app', + }); + sessionStore.setRuntimeHints(ref.address, { platform: 'android', metroHost: 'old' }); + mockInspectDeviceRuntimeFacts.mockImplementationOnce(async (target) => { + sessionStore.update(ref, { appBundleId: 'rebuilt.app' }); + return lifecycleRuntimeFacts(target); + }); + const response = await handleSessionCommands({ + req: { + token: 't', + session: ref.address, + command: 'runtime', + positionals: ['clear'], + flags: {}, + }, + sessionName: ref.address, + logPath: '/dev/null', + sessionStore, + invoke: noopInvoke, + }); + expect(response).toMatchObject({ ok: true, data: { cleared: true } }); + expect(mockClearRuntimeHints).toHaveBeenCalledWith( + expect.objectContaining({ appId: 'rebuilt.app' }), + ); + expect(refFrameState(sessionStore.requireCurrent(ref))).toBe('expired'); + expect(refFrameState(ref.session)).toBe('active'); +}); diff --git a/src/daemon/handlers/__tests__/session-script-publication.test.ts b/src/daemon/handlers/__tests__/session-script-publication.test.ts index 6c4f289358..074e66896c 100644 --- a/src/daemon/handlers/__tests__/session-script-publication.test.ts +++ b/src/daemon/handlers/__tests__/session-script-publication.test.ts @@ -87,7 +87,7 @@ function request(outputPath?: string, force?: boolean): DaemonRequest { test('publishes without close, returns the path/count, and leaves a terminal live session', () => { const outputPath = path.join(root, 'screen-x.ad'); const session = armedSession(); - store.set('authoring', session); + store.publish('authoring', session); const response = handleSessionScriptPublication({ req: request(outputPath), @@ -120,7 +120,7 @@ test('no-clobber failure preserves bytes and armed state, then --force retries s const outputPath = path.join(root, 'screen-x.ad'); fs.writeFileSync(outputPath, 'original\n'); const session = armedSession(); - store.set('authoring', session); + store.publish('authoring', session); const refused = handleSessionScriptPublication({ req: request(outputPath), @@ -149,7 +149,7 @@ test('retargeting without --force clears force authorization from the previous t const session = armedSession({ scriptPublication: authoringPublication('armed', { path: originalPath, force: true }), }); - store.set('authoring', session); + store.publish('authoring', session); const response = handleSessionScriptPublication({ req: request(retargetPath), @@ -166,7 +166,7 @@ test('retargeting without --force clears force authorization from the previous t test('refuses unarmed and repair-owned sessions before filesystem work', () => { const outputPath = path.join(root, 'missing', 'screen-x.ad'); - store.set('authoring', makeIosSession('authoring')); + const unarmedRef = store.publish('authoring', makeIosSession('authoring')); const unarmed = handleSessionScriptPublication({ req: request(outputPath), sessionName: 'authoring', @@ -178,7 +178,8 @@ test('refuses unarmed and repair-owned sessions before filesystem work', () => { }); expect(fs.existsSync(path.dirname(outputPath))).toBe(false); - store.set( + store.retire(unarmedRef); + store.publish( 'authoring', armedSession({ scriptPublication: repairPublication('armed', { boundary: 0 }) }), ); @@ -196,7 +197,7 @@ test('refuses unarmed and repair-owned sessions before filesystem work', () => { test('rejects an explicitly empty destination path', () => { const session = armedSession(); - store.set('authoring', session); + store.publish('authoring', session); const response = handleSessionScriptPublication({ req: request(''), @@ -219,7 +220,7 @@ test('invalid destination guard remains armed and creates no target directory', { ts: 2, command: 'wait', positionals: ['stable'], flags: {} }, ], }); - store.set('authoring', session); + store.publish('authoring', session); const response = handleSessionScriptPublication({ req: request(outputPath), @@ -247,7 +248,7 @@ test('missing initial open is non-retriable within the armed session', () => { }, ], }); - store.set('authoring', session); + store.publish('authoring', session); const response = handleSessionScriptPublication({ req: request(outputPath), @@ -295,7 +296,7 @@ test('publishes leading-at typed values without mistaking them for session refs' }, ], }); - store.set('authoring', session); + store.publish('authoring', session); const response = handleSessionScriptPublication({ req: request(outputPath), @@ -330,7 +331,7 @@ test('refuses mutating find steps that cannot enforce target identity on replay' }, ], }); - store.set('authoring', session); + store.publish('authoring', session); const response = handleSessionScriptPublication({ req: request(outputPath), diff --git a/src/daemon/handlers/__tests__/session-selector-dispatch.test.ts b/src/daemon/handlers/__tests__/session-selector-dispatch.test.ts index 8f9d32fa61..3b8750fc95 100644 --- a/src/daemon/handlers/__tests__/session-selector-dispatch.test.ts +++ b/src/daemon/handlers/__tests__/session-selector-dispatch.test.ts @@ -28,6 +28,8 @@ import type { SessionState } from '../../session-state.ts'; import { handleSessionCommands } from './session-command-harness.ts'; import { refFrameState } from '../../ref-frame.ts'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; +import { createUnavailableRuntimeFactsForTest } from '../../../__tests__/test-utils/runtime-operation-facts.ts'; const available = Object.freeze({ available: true } as const); const keyboardFamilyDenial = Object.freeze({ @@ -35,6 +37,90 @@ const keyboardFamilyDenial = Object.freeze({ reason: 'owner-capability-missing' as const, }); +test.each(['rebuild', 'retire'] as const)( + 'app-event metadata and action stay in the admitted lifetime across %s', + async (transition) => { + vi.stubEnv('AGENT_DEVICE_IOS_APP_EVENT_URL_TEMPLATE', 'https://example.test/{event}'); + try { + const sessionStore = makeSessionStore(); + const address = 'cwd:held-app-event:default'; + const ref = sessionStore.publish(address, makeIosSession('default')); + const device = ref.session.device; + mockResolveTargetDevice.mockResolvedValue(device); + const owner = localRuntimeOwner(device.platform); + const base = createUnavailableRuntimeFactsForTest(device, owner); + const facts = { + ...base, + operations: { ...base.operations, triggerAppEvent: available, ensureReady: available }, + }; + let start!: () => void; + let release!: () => void; + const started = new Promise((resolve) => { + start = resolve; + }); + const released = new Promise((resolve) => { + release = resolve; + }); + const binding: DeviceBinding = { + device, + owner, + facts, + operations: { + ensureReady: async () => device, + triggerAppEvent: async () => { + start(); + await released; + }, + }, + [Symbol.asyncDispose]: async () => {}, + }; + const running = handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'trigger-app-event', + positionals: ['ready'], + flags: {}, + }, + sessionName: address, + sessionStore, + logPath: '/tmp/daemon.log', + invoke: noopInvoke, + inspectFacts: async () => facts, + bindDevice: async (_device, use) => narrowDeviceBinding(binding, use), + }); + await started; + let current = ref.session; + if (transition === 'rebuild') + current = sessionStore.update(ref, { + actions: [], + trace: { outPath: '/latest.trace', startedAt: 1 }, + }); + else { + sessionStore.retire(ref); + current = sessionStore.publish(address, makeIosSession('default')).session; + } + release(); + if (transition === 'rebuild') { + expect(await running).toMatchObject({ ok: true }); + expect(sessionStore.requireCurrent(ref).trace).toEqual(current.trace); + expect(sessionStore.requireCurrent(ref).appBundleId).toBe('com.apple.mobilesafari'); + expect(current.actions.map((action) => action.command)).toEqual(['trigger-app-event']); + expect(ref.session.actions).toEqual([]); + } else { + await expect(running).rejects.toMatchObject({ + details: { reason: 'session_lifetime_ended' }, + }); + expect(sessionStore.get(address)).toBe(current); + expect(current.appBundleId).toBeUndefined(); + expect(current.actions).toEqual([]); + } + } finally { + vi.unstubAllEnvs(); + } + }, +); + /** Admits every keyboard operation so the ADR 0014 seam runs on real admission, not a rejection. * `keyboardDismiss` is overridable so a test can force the invocation itself to reject, proving * the frame expires before the mutating call runs rather than only after it resolves. */ @@ -87,7 +173,7 @@ test('keyboard dismiss crosses the ADR 0014 seam while keyboard status preserves const { inspectFacts, bindDevice } = keyboardCapableRuntime(device); // dismiss mutates the device → frame expires. - sessionStore.set(sessionName, makeSession(sessionName, device)); + const dismissRef = sessionStore.publish(sessionName, makeSession(sessionName, device)); await handleSessionCommands({ req: { token: 't', @@ -106,7 +192,8 @@ test('keyboard dismiss crosses the ADR 0014 seam while keyboard status preserves expect(refFrameState(sessionStore.get(sessionName)!)).toBe('expired'); // status is a read-only probe → frame preserved (undefined === active). - sessionStore.set(sessionName, makeSession(sessionName, device)); + sessionStore.retire(dismissRef); + sessionStore.publish(sessionName, makeSession(sessionName, device)); await handleSessionCommands({ req: { token: 't', @@ -148,7 +235,7 @@ test('keyboard dismiss expires the frame before the invocation runs, even when i }, }); - sessionStore.set(sessionName, makeSession(sessionName, device)); + sessionStore.publish(sessionName, makeSession(sessionName, device)); await expect( handleSessionCommands({ req: { diff --git a/src/daemon/handlers/__tests__/snapshot-alert.test.ts b/src/daemon/handlers/__tests__/snapshot-alert.test.ts index f759a84beb..c6697c1334 100644 --- a/src/daemon/handlers/__tests__/snapshot-alert.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-alert.test.ts @@ -61,7 +61,7 @@ beforeEach(() => { test('alert accept retries a typed alert absence and succeeds on the second attempt', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-sim'; - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); let calls = 0; mockRunnerCommand.mockImplementation(async () => { @@ -85,7 +85,7 @@ test('alert accept retries a typed alert absence and succeeds on the second atte test('alert accept adds a scoped-snapshot hint after retrying alert-not-found failures', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-sim'; - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); mockRunnerCommand.mockRejectedValue(alertAbsence()); let thrown: unknown; @@ -103,7 +103,7 @@ test('alert accept adds a scoped-snapshot hint after retrying alert-not-found fa test('alert dismiss retries a typed absence whatever the message says', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-sim'; - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); let calls = 0; mockRunnerCommand.mockImplementation(async () => { diff --git a/src/daemon/handlers/__tests__/snapshot-handler-freshness.test.ts b/src/daemon/handlers/__tests__/snapshot-handler-freshness.test.ts index b9b340b0fe..7e709aa4bb 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler-freshness.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler-freshness.test.ts @@ -93,7 +93,7 @@ function makeAndroidTimeoutEvidenceSession(sessionName: string): SessionStore { createdAt: Date.now(), backend: 'android', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); return sessionStore; } @@ -165,7 +165,7 @@ test('snapshot annotations survive a deferred post-gesture capture into CLI JSON const snapshotQuality = { state: 'healthy', backend: 'tree' }; session.snapshot = { nodes: baselineNodes, createdAt: Date.now(), backend: 'android' }; session.postGestureStabilization = { action: 'swipe', positionals: [], markedAt: Date.now() }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockResolvedValue({ nodes: changedNodes, @@ -226,7 +226,7 @@ test('snapshot warns when recent snapshot node count collapses sharply', async ( createdAt: Date.now(), backend: 'android', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockResolvedValue( androidCapture( @@ -267,7 +267,7 @@ test('snapshot does not warn on expected node drop across presentation modes', a backend: 'xctest', presentationKey: buildSnapshotPresentationKey({ interactiveOnly: false }), }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockResolvedValue({ nodes: Array.from({ length: 8 }, (_, index) => ({ @@ -302,7 +302,7 @@ test('snapshot automatically retries stale Android trees after recent navigation const sessionStore = makeSessionStore(); const sessionName = 'android-stale-retries-to-fresh'; const session = makeAndroidFreshnessSession(sessionName, 'press', inboxBaselineNodes(24)); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture .mockResolvedValueOnce( @@ -340,7 +340,7 @@ test('snapshot warns when Android freshness retries still return the previous ro const sessionStore = makeSessionStore(); const sessionName = 'android-stale-after-press'; const session = makeAndroidFreshnessSession(sessionName, 'press', inboxBaselineNodes(24)); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockResolvedValue( androidCapture(androidTextRows(24, inboxRow), { rawNodeCount: 24, maxDepth: 2 }), @@ -367,7 +367,7 @@ test('snapshot warns when Android freshness retries still return the previous ro test('snapshot response includes normalized visibility metadata', async () => { const sessionStore = makeSessionStore(); const sessionName = 'android-visibility'; - sessionStore.set(sessionName, makeSession(sessionName, androidDevice)); + sessionStore.publish(sessionName, makeSession(sessionName, androidDevice)); legacyDispatchCapture.mockResolvedValue({ nodes: [ @@ -416,7 +416,7 @@ test('diff snapshot carries stale-tree warnings for recent Android presses', asy const sessionStore = makeSessionStore(); const sessionName = 'android-diff-stale-after-press'; const session = makeAndroidFreshnessSession(sessionName, 'press', inboxBaselineNodes(24)); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockResolvedValue( androidCapture(androidTextRows(24, inboxRow), { rawNodeCount: 24, maxDepth: 2 }), @@ -451,7 +451,7 @@ test('Android ref refresh mode does not retry narrow snapshots as sharp drops', 'press', buildNodes(androidTextRows(50, (row) => `Previous row ${row}`)), ); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockResolvedValue( androidCapture( diff --git a/src/daemon/handlers/__tests__/snapshot-handler-wait.test.ts b/src/daemon/handlers/__tests__/snapshot-handler-wait.test.ts index 809b64c98a..990bb1ffd2 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler-wait.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler-wait.test.ts @@ -82,7 +82,7 @@ async function runWaitCommand( positionals: string[], ) { const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, makeSession(sessionName, device, appAttach(device))); + sessionStore.publish(sessionName, makeSession(sessionName, device, appAttach(device))); return await handleSnapshotCommands({ req: snapshotRequest(sessionName, 'wait', { positionals }), sessionName, @@ -177,7 +177,7 @@ test('wait text on Android uses freshness-aware capture instead of one-shot snap const sessionStore = makeSessionStore(); const sessionName = 'android-wait-freshness'; const session = makeAndroidFreshnessSession(sessionName, 'press', inboxBaselineNodes(18)); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture .mockResolvedValueOnce( @@ -359,7 +359,7 @@ test('wait selector bypasses a fresh matching session snapshot', async () => { }, ], }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockResolvedValue({ nodes: [ { diff --git a/src/daemon/handlers/__tests__/snapshot-handler.test.ts b/src/daemon/handlers/__tests__/snapshot-handler.test.ts index fada858f56..255eb439a5 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler.test.ts @@ -69,7 +69,7 @@ beforeEach(() => { test('snapshot rejects @ref scope without existing session snapshot', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-sim'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { platform: 'apple', @@ -98,7 +98,7 @@ test('snapshot rejects @ref scope without existing session snapshot', async () = test('snapshot on iOS rejects sessions without a tracked app', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-sim-no-app'; - sessionStore.set(sessionName, makeSession(sessionName, iosSimulatorDevice)); + sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice)); const runtime = countingSnapshotRuntime(); const response = await handleSnapshotCommands({ @@ -128,7 +128,7 @@ test('snapshot on iOS without a tracked app carries the detected open command as ); const sessionStore = makeSessionStore(); const sessionName = 'ios-sim-no-app-hinted'; - sessionStore.set(sessionName, makeSession(sessionName, iosSimulatorDevice)); + sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice)); const response = await handleSnapshotCommands({ req: snapshotRequest(sessionName, 'snapshot'), @@ -155,7 +155,7 @@ test('snapshot on iOS without a tracked app carries the detected open command as test('snapshot on provider-backed iOS runs without a tracked app', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-cloud-no-app'; - sessionStore.set(sessionName, makeSession(sessionName, providerIosDevice)); + sessionStore.publish(sessionName, makeSession(sessionName, providerIosDevice)); setActiveProviderDeviceRuntimes([makeProviderRuntimeOwning(providerIosDevice)]); legacyDispatchCapture.mockResolvedValue({ nodes: [{ index: 0, depth: 0, type: 'XCUIElementTypeButton', label: 'Sign in' }], @@ -185,7 +185,7 @@ test('snapshot on provider-backed iOS runs without a tracked app', async () => { test('diff on local iOS still requires a tracked app', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-sim-no-app-diff'; - sessionStore.set(sessionName, makeSession(sessionName, iosSimulatorDevice)); + sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice)); const response = await handleSnapshotCommands({ req: snapshotRequest(sessionName, 'diff', { positionals: ['snapshot'] }), @@ -205,7 +205,7 @@ test('diff on local iOS still requires a tracked app', async () => { test('snapshot on iOS runs when the session tracks an app', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-sim-app'; - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, iosSimulatorDevice), appBundleId: 'org.reactnavigation.playground', }); @@ -243,7 +243,7 @@ test('snapshot re-activates a complete frame; diff preserves it (ADR 0014)', asy }; // A prior device action expired the frame. expireRefFrame(session); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockResolvedValue({ nodes: [{ index: 0, depth: 0, type: 'android.widget.Button', label: 'Fresh' }], truncated: false, @@ -295,7 +295,7 @@ async function runVersionedRefsCommand(params: { function makeVersionedRefsScenario(sessionName: string) { const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, makeSession(sessionName, androidDevice)); + sessionStore.publish(sessionName, makeSession(sessionName, androidDevice)); legacyDispatchCapture.mockResolvedValue({ nodes: [{ index: 0, depth: 0, type: 'android.widget.Button', label: 'Fresh' }], truncated: false, @@ -384,7 +384,7 @@ test('daemon-private snapshot observation advances capture state without publish test('snapshot surfaces filtered-to-zero Android guidance for interactive snapshots', async () => { const sessionStore = makeSessionStore(); const sessionName = 'android-empty-interactive'; - sessionStore.set(sessionName, makeSession(sessionName, androidDevice)); + sessionStore.publish(sessionName, makeSession(sessionName, androidDevice)); legacyDispatchCapture.mockResolvedValue(androidCapture([], { rawNodeCount: 42, maxDepth: 8 })); diff --git a/src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts b/src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts index 57ca82d23a..ccd14733c9 100644 --- a/src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts @@ -18,7 +18,7 @@ test('snapshot resolves @ref scope with the stored source after scoped output re const sessionStore = makeSessionStore('agent-device-snapshot-scoped-refs-'); const sessionName = 'android-ref-scope-repeat'; const session = makeAndroidSession(sessionName, { snapshot: androidRefScopeSourceSnapshot() }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockResolvedValue({ nodes: scopedScriptErrorNodes(), @@ -46,7 +46,7 @@ test('a mutation clears scoped-snapshot lineage so a repeated snapshot -s @ref c const sessionStore = makeSessionStore('agent-device-snapshot-scoped-refs-'); const sessionName = 'android-ref-scope-broken-by-mutation'; const session = makeAndroidSession(sessionName, { snapshot: androidRefScopeSourceSnapshot() }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockResolvedValue({ nodes: scopedScriptErrorNodes(), @@ -75,7 +75,7 @@ test('empty @ref-scoped snapshot output does not replace the stored session snap const sessionStore = makeSessionStore('agent-device-snapshot-scoped-refs-'); const sessionName = 'android-empty-scope-preserve'; const session = makeAndroidSession(sessionName, { snapshot: currentScreenSnapshot() }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockResolvedValue({ nodes: [], diff --git a/src/daemon/handlers/__tests__/snapshot-settings-handler.test.ts b/src/daemon/handlers/__tests__/snapshot-settings-handler.test.ts index 12f0e4c36f..4415941789 100644 --- a/src/daemon/handlers/__tests__/snapshot-settings-handler.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-settings-handler.test.ts @@ -80,7 +80,7 @@ beforeEach(() => { test('settings rejects unsupported iOS physical devices', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-device'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { platform: 'apple', @@ -109,7 +109,7 @@ test('settings rejects unsupported iOS physical devices', async () => { test('settings clear-app-state dispatches explicit app id without an active app session', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-clear-state'; - sessionStore.set(sessionName, makeSession(sessionName, iosSimulatorDevice)); + sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice)); const response = await handleSnapshotCommands({ req: snapshotRequest(sessionName, 'settings', { @@ -131,7 +131,7 @@ test('settings clear-app-state dispatches explicit app id without an active app test('settings clear-app-state rejects missing app id when no app session is bound', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-clear-state-missing-app'; - sessionStore.set(sessionName, makeSession(sessionName, iosSimulatorDevice)); + sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice)); const response = await handleSnapshotCommands({ req: snapshotRequest(sessionName, 'settings', { positionals: ['clear-app-state'] }), @@ -151,7 +151,7 @@ test('settings clear-app-state rejects missing app id when no app session is bou test('settings reset-keychain dispatches without an app id or active app session', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-reset-keychain'; - sessionStore.set(sessionName, makeSession(sessionName, iosSimulatorDevice)); + sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice)); const response = await handleSnapshotCommands({ req: snapshotRequest(sessionName, 'settings', { @@ -172,7 +172,7 @@ test('settings reset-keychain dispatches without an app id or active app session test('settings reset-keychain rejects an extra app argument instead of dropping it', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-reset-keychain-extra-arg'; - sessionStore.set(sessionName, makeSession(sessionName, iosSimulatorDevice)); + sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice)); const response = await handleSnapshotCommands({ req: snapshotRequest(sessionName, 'settings', { @@ -193,7 +193,7 @@ test('settings reset-keychain rejects an extra app argument instead of dropping test('settings text-size reads the category the owner holds without mutating anything', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-text-size-read'; - sessionStore.set(sessionName, makeSession(sessionName, iosSimulatorDevice)); + sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice)); const response = await handleSnapshotCommands({ req: snapshotRequest(sessionName, 'settings', { positionals: ['text-size'] }), @@ -216,7 +216,7 @@ test('settings text-size reads the category the owner holds without mutating any test('settings text-size refuses the macOS host on both legs with the same code', async () => { const sessionStore = makeSessionStore(); const sessionName = 'macos-text-size-read'; - sessionStore.set(sessionName, makeSession(sessionName, macOsDevice)); + sessionStore.publish(sessionName, makeSession(sessionName, macOsDevice)); const read = await handleSnapshotCommands({ req: snapshotRequest(sessionName, 'settings', { positionals: ['text-size'] }), @@ -247,7 +247,7 @@ test('settings text-size applies a ladder category through the write leg', async const sessionName = 'ios-text-size-write'; const session = makeSession(sessionName, iosSimulatorDevice); activateCompleteRefFrame(session); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await handleSnapshotCommands({ req: snapshotRequest(sessionName, 'settings', { @@ -278,7 +278,7 @@ test('settings text-size refuses an Apple leaf with no content size before it ex const sessionName = 'tvos-text-size'; const session = makeSession(sessionName, tvOsSimulatorDevice); activateCompleteRefFrame(session); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); for (const positionals of [['text-size'], ['text-size', 'large']]) { const response = await handleSnapshotCommands({ @@ -304,7 +304,7 @@ test('settings text-size refuses an Apple leaf with no content size before it ex test('settings text-size refuses an off-ladder category with the whole ladder', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-text-size-invalid'; - sessionStore.set(sessionName, makeSession(sessionName, iosSimulatorDevice)); + sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice)); const response = await handleSnapshotCommands({ req: snapshotRequest(sessionName, 'settings', { positionals: ['text-size', 'gigantic'] }), @@ -349,7 +349,7 @@ test('settings usage hint documents canonical faceid states', async () => { test('settings on macOS rejects wifi before dispatch with explicit subset guidance', async () => { const sessionStore = makeSessionStore(); const sessionName = 'macos-settings-wifi'; - sessionStore.set(sessionName, makeSession(sessionName, macOsDevice)); + sessionStore.publish(sessionName, makeSession(sessionName, macOsDevice)); const response = await handleSnapshotCommands({ req: snapshotRequest(sessionName, 'settings', { positionals: ['wifi', 'on'] }), diff --git a/src/daemon/handlers/__tests__/trace-runtime.test.ts b/src/daemon/handlers/__tests__/trace-runtime.test.ts index d06b58c472..2966e5bac0 100644 --- a/src/daemon/handlers/__tests__/trace-runtime.test.ts +++ b/src/daemon/handlers/__tests__/trace-runtime.test.ts @@ -22,7 +22,7 @@ function fixture() { createdAt: Date.now(), actions: [], }; - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); return { root, session, sessionStore }; } diff --git a/src/daemon/handlers/__tests__/wait-landmark-recording.test.ts b/src/daemon/handlers/__tests__/wait-landmark-recording.test.ts index fca72e152e..5b8124c022 100644 --- a/src/daemon/handlers/__tests__/wait-landmark-recording.test.ts +++ b/src/daemon/handlers/__tests__/wait-landmark-recording.test.ts @@ -89,7 +89,7 @@ async function runWait(options: { recording?: boolean; req?: DaemonRequest } = { 'default', options.recording ? { scriptPublication: authoringPublication('armed') } : {}, ); - sessionStore.set('default', session); + sessionStore.publish('default', session); const response = await dispatchWaitViaRuntime({ req: options.req ?? waitReq(), sessionName: 'default', diff --git a/src/daemon/handlers/react-native.ts b/src/daemon/handlers/react-native.ts index fadabcab01..5711d69785 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,12 +29,13 @@ import { errorResponse, noActiveSessionError } from '@agent-device/kernel/contra export async function handleReactNativeCommands( params: InteractionRouteInput, ): Promise { - const { req, sessionName, sessionStore } = params; + params = bindInteractionSession(params); + const { req, sessionStore } = params; if (req.command !== PUBLIC_COMMANDS.reactNative) return null; const parsed = parseReactNativeArgs(req.positionals ?? []); if (!parsed.ok) return parsed.response; - const session = sessionStore.get(sessionName); + const session = params.sessionRef ? sessionStore.requireCurrent(params.sessionRef) : undefined; if (!session) return noActiveSessionError(); // R61: admission is the owner's own `tapPoint` fact — the one operation this command executes. // It runs before the observing capture, exactly where the retired capability gate ran, so an @@ -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, @@ -180,7 +182,7 @@ async function executeReactNativeOverlayDismiss( ...successText(formatDismissMessage(verification)), }); return finalizeTouchInteraction({ - session, + ref: params.sessionRef!, sessionStore, command: req.command, positionals: req.positionals ?? [], @@ -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/record-runtime.ts b/src/daemon/handlers/record-runtime.ts index 758bd95bc4..e7c698bb08 100644 --- a/src/daemon/handlers/record-runtime.ts +++ b/src/daemon/handlers/record-runtime.ts @@ -4,6 +4,7 @@ import type { ScreenRecordingCompletion, ScreenRecordingStartInput, } from '@agent-device/contracts/screen-recording-runtime'; +import { bindSessionScreenRecording } from '../session-capture-binding.ts'; import { resolveScreenRecordingRuntimePlan, screenRecordingAdmissionUse, @@ -30,7 +31,8 @@ import { resolveSessionScope } from '../session-routing.ts'; import type { SessionStore } from '../session-store.ts'; import type { BindDeviceRuntime, BindExactDeviceRuntime } 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 { bindRecordOnlyScreenRecording } from '../screen-recording-session-binding.ts'; import { recordSessionAction } from '../session-action-recorder.ts'; import { missingAppSessionResponse, @@ -80,23 +82,31 @@ async function handleRecordCommandUnsafe( params: RecordRuntimeHandlerParams, ): Promise { const { req, sessionName, sessionStore } = params; - const existingSession = sessionStore.get(sessionName); + const existingRef = sessionStore.lookup(sessionName); + const existingSession = existingRef?.session; const { plan, scope } = resolveRecordPlan(req, existingSession); if (plan.kind === 'start' && !isWholeScreenRecordingScope(scope) && !existingSession) { return missingAppSessionResponse(req); } - const resolvedSession = await resolveRecordingSession(params, existingSession); + const resolvedSession = await resolveRecordingSession(params, existingRef); const { session } = resolvedSession; if (plan.kind === 'start') { return await startRecording( params, session, + resolvedSession.ref, prepareRecordingRequest(req), plan.use, resolvedSession.needsReadiness, ); } - return await stopRecording(params, session, plan.kind, resolvedSession.needsReadiness); + return await stopRecording( + params, + session, + resolvedSession.ref, + plan.kind, + resolvedSession.needsReadiness, + ); } function resolveRecordPlan(req: DaemonRequest, session: SessionState | undefined) { @@ -113,17 +123,18 @@ function resolveRecordPlan(req: DaemonRequest, session: SessionState | undefined async function resolveRecordingSession( params: RecordRuntimeHandlerParams, - existing: SessionState | undefined, -): Promise> { - const device = existing?.device ?? (await resolveTargetDevice(params.req.flags ?? {})); + ref: SessionRef | undefined, +): Promise> { + const device = ref?.session.device ?? (await resolveTargetDevice(params.req.flags ?? {})); await params.retainDeviceExecutionLock(device.id); - if (existing) return { session: existing, needsReadiness: false }; + if (ref) return { session: params.sessionStore.requireCurrent(ref), ref, needsReadiness: false }; return { session: createRecordOnlySession(params, device), needsReadiness: true }; } async function startRecording( params: RecordRuntimeHandlerParams, session: SessionState, + ref: SessionRef | undefined, prepared: ReturnType, use: typeof screenRecordingStartUse, needsReadiness: boolean, @@ -131,32 +142,37 @@ async function startRecording( if (session.screenRecording) { return { ok: false, error: { code: 'INVALID_ARGS', message: 'recording already in progress' } }; } + const draft = ref + ? undefined + : bindRecordOnlyScreenRecording(params.sessionStore, params.sessionName, session); + const binding = ref ? bindSessionScreenRecording(params.sessionStore, ref) : draft!.binding; + binding.assertAdoptable(); const admission = await params.bindDevice(session.device, screenRecordingAdmissionUse); if (needsReadiness) await ensureBoundDeviceReady(admission); const startFact = admission.facts.screenRecordingStart; if (!startFact.available) return buildRecordingUnsupportedResponse(startFact); const runtime = await params.bindDevice(session.device, use); const { fence, outputPaths } = prepareRecordingStart(params, session); + binding.assertAdoptable(); const started = await runtime.operations.screenRecordingStart( screenRecordingStartInput(params, session, prepared, fence, outputPaths.outputPath), ); await adoptStartedScreenRecording({ admissionLedger: params.admissionLedger, - session, - sessionName: params.sessionName, - sessionStore: params.sessionStore, + binding, device: session.device, owner: runtime.owner, fence, ...started, throwIfCanceled: params.throwIfCanceled, }); - const adopted = params.sessionStore.get(params.sessionName)?.screenRecording; + const adoptedRef = ref ?? draft!.requireRef(); + const adopted = binding.read(); if (!adopted) throw new TypeError('Screen recording adoption did not publish a live handle'); const snapshot = adopted.handle.inspect(); recordSessionAction( params.sessionStore, - session, + adoptedRef, params.req, params.req.command, buildRecordingStartedAction(snapshot), @@ -219,6 +235,7 @@ function recordingAppIdentity( async function stopRecording( params: RecordRuntimeHandlerParams, session: SessionState, + ref: SessionRef | undefined, kind: 'stop-live' | 'stop-recovery', needsReadiness: boolean, ): Promise { @@ -229,21 +246,19 @@ async function stopRecording( ? { completion: await finishLiveScreenRecording({ intent: 'capture', - session, - sessionName: params.sessionName, - sessionStore: params.sessionStore, + binding: bindSessionScreenRecording(params.sessionStore, ref!), }), recordsSessionAction: true, } : await finishRecovered(params, session, needsReadiness); } catch (error) { - deleteTerminalRecordOnlySession(params, session); + deleteTerminalRecordOnlySession(params, session, ref); throw error; } const completion = stopped.completion; const response = buildRecordingStopResponse(completion); if (stopped.recordsSessionAction) { - recordSessionAction(params.sessionStore, session, params.req, params.req.command, { + recordSessionAction(params.sessionStore, ref, params.req, params.req.command, { action: 'stop', outPath: completion.outPath, ...(completion.clientOutPath @@ -252,16 +267,17 @@ async function stopRecording( showTouches: completion.showTouches, }); } - if (session.recordOnlySession) params.sessionStore.delete(params.sessionName); + if (session.recordOnlySession && ref) params.sessionStore.retire(ref); return response; } function deleteTerminalRecordOnlySession( params: Pick, session: SessionState, + ref: SessionRef | undefined, ): void { - if (!session.recordOnlySession) return; - if (screenRecordingManifestIsTerminal(params)) params.sessionStore.delete(params.sessionName); + if (!session.recordOnlySession || !ref) return; + if (screenRecordingManifestIsTerminal(params)) params.sessionStore.retire(ref); } async function finishRecovered( diff --git a/src/daemon/handlers/session-app-deployment.ts b/src/daemon/handlers/session-app-deployment.ts index edf468e306..51e8d79efc 100644 --- a/src/daemon/handlers/session-app-deployment.ts +++ b/src/daemon/handlers/session-app-deployment.ts @@ -1,3 +1,4 @@ +import { expandSessionPath } from '@agent-device/host-kit/session-paths'; import fs from 'node:fs'; import type { AppDeploymentResult } from '@agent-device/contracts/app-deployment-runtime'; import { @@ -9,9 +10,9 @@ import { readNotificationPayload } from '../dispatch-payload.ts'; import { cleanupUploadedArtifact, prepareUploadedArtifact } from '../artifact-tracking.ts'; import { expireRefFrame } from '../ref-frame.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; -import { SessionStore } from '../session-store.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'; import { resolvePayloadInput } from '../payload-input.ts'; import { resolveDeployResultTarget } from '../../core/deploy-result-target.ts'; import { withSuccessText } from '@agent-device/kernel/success-text'; @@ -51,7 +52,8 @@ export async function handleAppDeploymentCommand(params: { bindDevice?: BindDeviceRuntime; }): Promise { const { req, command, sessionName, sessionStore } = params; - const session = sessionStore.get(sessionName); + const ref = sessionStore.lookup(sessionName); + const session = ref?.session; const flags = req.flags ?? {}; const guard = requireSessionOrExplicitSelector(command, session, flags); if (guard) return guard; @@ -62,7 +64,7 @@ export async function handleAppDeploymentCommand(params: { try { const appPath = uploadedArtifactId ? prepareUploadedArtifact(uploadedArtifactId, req.meta?.tenantId) - : SessionStore.expandHome(target.appPathInput); + : expandSessionPath(target.appPathInput); if (!fs.existsSync(appPath)) { return errorResponse('INVALID_ARGS', `App binary not found: ${appPath}`); } @@ -75,7 +77,7 @@ export async function handleAppDeploymentCommand(params: { const runtime = await requireRuntimeBinding(params.bindDevice)(device, deployAppUse); // ADR 0014: deployment can replace the visible surface. Do this immediately before the // bound operation so an admission failure never discards a still-valid reference frame. - if (session) expireRefFrame(session); + if (ref) expireRefFrame(sessionStore.requireCurrent(ref)); const deployment = await runtime.operations.deployApp({ app: target.app, appPath, @@ -86,11 +88,8 @@ export async function handleAppDeploymentCommand(params: { result, `Installed: ${result.appName ?? resolveDeployResultTarget(result)}`, ); - const sessionForRecord = updateHarmonyDeploymentSession(session, result); - if (sessionForRecord && sessionForRecord !== session) { - sessionStore.set(sessionName, sessionForRecord); - } - recordSessionAction(sessionStore, sessionForRecord, req, command, data); + updateHarmonyDeploymentSession(sessionStore, ref, result); + recordSessionAction(sessionStore, ref, req, command, data); return { ok: true, data }; } finally { if (uploadedArtifactId) cleanupUploadedArtifact(uploadedArtifactId); @@ -101,7 +100,8 @@ export async function handlePushNotificationCommand( params: RuntimeCommandHandlerParams, ): Promise { const { req, sessionName, sessionStore } = params; - const session = sessionStore.get(sessionName); + const ref = sessionStore.lookup(sessionName); + const session = ref?.session; const flags = req.flags ?? {}; const guard = requireSessionOrExplicitSelector('push', session, flags); if (guard) return guard; @@ -130,7 +130,7 @@ export async function handlePushNotificationCommand( // ADR 0014: a notification dispatch may change the visible surface. Keep an admission failure // non-destructive, but expire immediately before dispatch so an attempted provider operation // that fails after crossing the side-effect seam cannot leave stale refs authorized. - if (session) expireRefFrame(session); + if (ref) expireRefFrame(sessionStore.requireCurrent(ref)); const result = await runtime.operations.sendPushNotification({ appId, payload }); const data = isIosFamily(device) ? withSuccessText({ platform: 'ios', bundleId: appId }, `Pushed notification to ${appId}`) @@ -143,7 +143,7 @@ export async function handlePushNotificationCommand( }, `Pushed notification to ${appId}`, ); - recordSessionAction(sessionStore, session, req, 'push', data, { + recordSessionAction(sessionStore, ref, req, 'push', data, { positionals: [appId, payloadArg], }); return { ok: true, data }; @@ -197,15 +197,15 @@ function buildNonIosDeployResult( } function updateHarmonyDeploymentSession( - session: SessionState | undefined, + sessionStore: SessionStore, + ref: SessionRef | undefined, result: DeployResult, -): SessionState | undefined { - if (!session || result.platform !== 'harmonyos' || !result.appId) return session; - return { - ...session, +): void { + if (!ref || result.platform !== 'harmonyos' || !result.appId) return; + sessionStore.update(ref, { appBundleId: result.appId, appName: result.appName ?? result.app, - }; + }); } function resolveDeployTarget( @@ -242,7 +242,7 @@ function resolvePushPayload(payloadArg: string, cwd?: string): string { const resolved = resolvePayloadInput(payloadArg, { subject: 'Push payload', cwd, - expandPath: (value, currentCwd) => SessionStore.expandHome(value, currentCwd), + expandPath: (value, currentCwd) => expandSessionPath(value, currentCwd), }); return resolved.kind === 'file' ? resolved.path : resolved.text; } diff --git a/src/daemon/handlers/session-app-source-deployment.ts b/src/daemon/handlers/session-app-source-deployment.ts index c1ee5a432c..013c0bf27a 100644 --- a/src/daemon/handlers/session-app-source-deployment.ts +++ b/src/daemon/handlers/session-app-source-deployment.ts @@ -51,7 +51,8 @@ export async function handleInstallFromSourceDeploymentCommand(params: { bindDevice?: BindDeviceRuntime; }): Promise { const { req, sessionName, sessionStore } = params; - const session = sessionStore.get(sessionName); + const ref = sessionStore.lookup(sessionName); + const session = ref?.session; let resolvedSource: ReturnType | undefined; let materialized: MaterializedAppSource | undefined; let retained: RetainedMaterializedPaths | undefined; @@ -88,14 +89,14 @@ export async function handleInstallFromSourceDeploymentCommand(params: { // ADR 0014 side-effect seam: materialization is request-local, but deployment can // replace the visible app surface. Expire immediately before its sole bound dispatch, // so admission or materialization failures preserve refs while a dispatch rejection does not. - if (session) expireRefFrame(session); + if (ref) expireRefFrame(sessionStore.requireCurrent(ref)); const deployment = await runtime.operations.deployMaterializedApp({ artifact: materialized }); const result = buildInstallFromSourceResult(device, materialized, deployment, retained); const data = withSuccessText( result, `Installed: ${resolveInstallFromSourceResultTarget(result)}`, ); - recordSessionAction(sessionStore, session, req, 'install_source', data, { positionals: [] }); + recordSessionAction(sessionStore, ref, req, 'install_source', data, { positionals: [] }); return { ok: true, data }; } catch (error) { if (retained) { diff --git a/src/daemon/handlers/session-clipboard.ts b/src/daemon/handlers/session-clipboard.ts index 869e618d62..b4522ca903 100644 --- a/src/daemon/handlers/session-clipboard.ts +++ b/src/daemon/handlers/session-clipboard.ts @@ -143,7 +143,8 @@ export async function handleSessionClipboardCommand(params: { bindDevice?: BindDeviceRuntime; }): Promise { const { req, sessionName, logPath, sessionStore, inspectFacts, bindDevice } = params; - const session = sessionStore.get(sessionName); + const ref = sessionStore.lookup(sessionName); + const session = ref?.session; const flags = req.flags ?? {}; const guard = requireSessionOrExplicitSelector(PUBLIC_COMMANDS.clipboard, session, flags); if (guard) return guard; @@ -155,6 +156,7 @@ export async function handleSessionClipboardCommand(params: { } const device = await resolveCommandDevice({ session, flags }); + if (ref) sessionStore.requireCurrent(ref); const bound = await resolveBoundClipboardRuntime({ device, action, @@ -163,10 +165,10 @@ export async function handleSessionClipboardCommand(params: { bindDevice, }); if (!bound.ok) return bound.response; - + const current = ref ? sessionStore.requireCurrent(ref) : undefined; const result = await bound.execute( - contextFromFlags(logPath, req.flags, session?.appBundleId, session?.trace?.outPath), + contextFromFlags(logPath, req.flags, current?.appBundleId, current?.trace?.outPath), ); - recordSessionAction(sessionStore, session, req, req.command, result); + recordSessionAction(sessionStore, ref, req, req.command, result); return { ok: true, data: { platform: publicPlatformString(device), ...result } }; } diff --git a/src/daemon/handlers/session-replay-command.ts b/src/daemon/handlers/session-replay-command.ts index ce15cc7685..29b3321094 100644 --- a/src/daemon/handlers/session-replay-command.ts +++ b/src/daemon/handlers/session-replay-command.ts @@ -112,7 +112,6 @@ export function createReplaySession( const session = store.get(name); if (!session) return false; mutate(session); - store.set(name, session); return true; }; // One read set for both views: the narrowed one the port binds over, and the full-record one the @@ -147,23 +146,24 @@ export function createReplaySession( device, platform, }), - bindAuthority: (signal) => - bindInternalObservationAuthority({ - sessionStore: { get: () => store.get(name), update: updateSession }, - sessionName: name, - ...(signal ? { signal } : {}), - }), - capture: async ({ flags, logPath: captureLogPath }) => { - const session = store.get(name); - if (!session) { - throw new AppError('NO_ACTIVE_SESSION', `Session "${name}" is no longer active.`); - } - return await captureSnapshot({ - device: session.device, - session, - flags, - logPath: captureLogPath, - }); + bindAuthority: (signal) => { + const ref = store.lookup(name); + return { + ...bindInternalObservationAuthority({ sessionStore: store, ref, signal }), + capture: async ({ flags, logPath: captureLogPath }) => { + const session = ref ? store.requireCurrent(ref) : undefined; + if (!session) { + throw new AppError('NO_ACTIVE_SESSION', `Session "${name}" is no longer active.`); + } + return await captureSnapshot({ + device: session.device, + session, + flags, + logPath: captureLogPath, + signal, + }); + }, + }; }, }); } diff --git a/src/daemon/handlers/session-runtime-command.ts b/src/daemon/handlers/session-runtime-command.ts index 8bdef171f8..0bea2f5106 100644 --- a/src/daemon/handlers/session-runtime-command.ts +++ b/src/daemon/handlers/session-runtime-command.ts @@ -2,6 +2,7 @@ import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; import { publicPlatformString } from '@agent-device/kernel/device'; import { clearRuntimeHintsRuntimeUse } from '@agent-device/contracts/application-lifecycle-runtime-plan'; import { SessionStore } from '../session-store.ts'; +import type { SessionRef } from '../session-state.ts'; import { expireRefFrame } from '../ref-frame.ts'; import { admitRuntimeUse } from '../runtime-admission.ts'; import { @@ -64,13 +65,14 @@ export async function handleRuntimeCommand(params: { 'runtime requires set, show, clear, port-reverse, or gesture-viewport', ); } - const session = sessionStore.get(sessionName); + const ref = sessionStore.lookup(sessionName); + const session = ref?.session; const current = sessionStore.getRuntimeHints(sessionName); if (action === 'clear') { return await clearRuntimeCommand({ sessionName, sessionStore, - session, + ref, current, inspectFacts: params.inspectFacts, bindDevice: params.bindDevice, @@ -118,12 +120,13 @@ function isRuntimeAction(action: string): action is RuntimeAction { async function clearRuntimeCommand(params: { sessionName: string; sessionStore: SessionStore; - session: ReturnType; + ref: SessionRef | undefined; current: ReturnType; inspectFacts?: InspectDeviceRuntimeFacts; bindDevice?: BindDeviceRuntime; }): Promise { - const { sessionName, sessionStore, session, current, inspectFacts, bindDevice } = params; + const { sessionName, sessionStore, ref, current, inspectFacts, bindDevice } = params; + const session = ref ? sessionStore.requireCurrent(ref) : undefined; if (hasRuntimeTransportHints(current) && session?.appBundleId) { const admission = await admitClearRuntime({ device: session.device, @@ -133,13 +136,15 @@ async function clearRuntimeCommand(params: { if (admission.type === 'response') return admission.response; // Native hint removal can change the app's reachable surface. Expire the existing frame at // the mutation boundary, after admission and immediately before the bound package effect. - expireRefFrame(session); + const currentSession = sessionStore.requireCurrent(ref!); + expireRefFrame(currentSession); await admission.runtime.operations.clearRuntimeHints({ - appId: session.appBundleId, + appId: currentSession.appBundleId, values: runtimeHintValues(current), }); } - const cleared = sessionStore.clearRuntimeHints(sessionName); + const cleared = ref ? sessionStore.clearRuntimeHints(ref) : Boolean(current); + if (!ref) sessionStore.setRuntimeHints(sessionName, undefined); return { ok: true, data: { session: sessionName, cleared } }; } 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/handlers/session-selector-dispatch.ts b/src/daemon/handlers/session-selector-dispatch.ts index c41e8cbf73..078a26e61b 100644 --- a/src/daemon/handlers/session-selector-dispatch.ts +++ b/src/daemon/handlers/session-selector-dispatch.ts @@ -1,7 +1,7 @@ import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { DaemonRequest, 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 { contextFromFlags } from '../context.ts'; import { @@ -32,9 +32,9 @@ type SessionCommandPrepareOutcome = /** * The one orchestration every session/selector-route leaf shares: guard, resolve the device, * admit-then-prepare via the caller's own strategy, expire the ref frame if the command mutates - * (immediately before the prepared invocation runs, never after), derive and record the next - * session. `prepare` is where each leaf's own admission and binding lives; everything around it - * is identical, so it lives here once instead of once per command. Every leaf on this route now + * (immediately before the prepared invocation runs, never after), apply the optional session + * patch and record the action. `prepare` owns each leaf's admission and binding; the shared + * orchestration lives here once. Every leaf on this route now * supplies a bind-and-execute thunk — R57 retired the last capability-gate-then-`dispatchCommand` * one with `trigger-app-event`. */ @@ -46,14 +46,10 @@ async function runSessionOrSelectorDispatch(params: { command: string; positionals: string[]; recordPositionals?: string[]; - deriveNextSession?: ( - session: SessionState, - result: Record | void, - device: DeviceInfo, - ) => Promise | SessionState; + updateSession?: (ref: SessionRef, result: Record | void) => Promise | void; prepare: ( device: DeviceInfo, - session: SessionState | undefined, + ref: SessionRef | undefined, ) => Promise; }): Promise { const { @@ -63,10 +59,11 @@ async function runSessionOrSelectorDispatch(params: { command, positionals, recordPositionals, - deriveNextSession, + updateSession, prepare, } = params; - const session = sessionStore.get(sessionName); + const ref = sessionStore.lookup(sessionName); + const session = ref?.session; const flags = req.flags ?? {}; const guard = requireSessionOrExplicitSelector(command, session, flags); if (guard) return guard; @@ -75,29 +72,26 @@ async function runSessionOrSelectorDispatch(params: { session, flags, }); - const prepared = await prepare(device, session); + if (ref) sessionStore.requireCurrent(ref); + const prepared = await prepare(device, ref); if (!prepared.ok) return prepared.response; // ADR 0014 side-effect seam for session/selector-route leaves (keyboard // dismiss/enter/return, push, trigger-app-event). Expire the frame immediately before the // mutating invocation runs — not after it resolves — when the classification says this // request mutates; keyboard status/get resolve to `preserve` and leave the frame untouched. - if (session && resolveRefFrameEffect(req) === 'may-invalidate') { - expireRefFrame(session); + const current = ref ? sessionStore.requireCurrent(ref) : undefined; + if (current && resolveRefFrameEffect(req) === 'may-invalidate') { + expireRefFrame(current); } const result = await prepared.execute(); - if (session) { - const nextSession = deriveNextSession - ? await deriveNextSession(session, result, device) - : session; - recordSessionAction(sessionStore, nextSession, req, command, result ?? {}, { + if (ref) { + if (updateSession) await updateSession(ref, result); + recordSessionAction(sessionStore, ref, req, command, result ?? {}, { positionals: recordPositionals ?? positionals, }); - if (nextSession !== session) { - sessionStore.set(sessionName, nextSession); - } } return { ok: true, data: result ?? {} }; } @@ -150,7 +144,7 @@ type SessionRouteRuntimeResolver = ( /** * The whole shape a migrated session-route leaf needs: admit and bind through the caller's own * resolver, then hand `runSessionOrSelectorDispatch` the bound runtime's `execute` to invoke after - * expiring the frame. Only the resolver, the command name and the optional session derivation + * expiring the frame. Only the resolver, the command name and the optional post-execution session patch * differ per leaf, so one entry point here is what keeps `keyboard` and `trigger-app-event` from * drifting into two copies of the same wiring. */ @@ -164,7 +158,7 @@ async function runBoundSessionRoute( * lexically. A bare reference would dedupe the wiring and delete the proof with it. */ resolveRuntime: SessionRouteRuntimeResolver; - deriveNextSession?: Parameters[0]['deriveNextSession']; + updateSession?: Parameters[0]['updateSession']; }>, ): Promise { const { req, sessionName, logPath, sessionStore, inspectFacts, bindDevice } = params; @@ -175,8 +169,8 @@ async function runBoundSessionRoute( sessionStore, command: params.command, positionals, - ...(params.deriveNextSession ? { deriveNextSession: params.deriveNextSession } : {}), - prepare: async (device, session) => { + ...(params.updateSession ? { updateSession: params.updateSession } : {}), + prepare: async (device, ref) => { const bound = await params.resolveRuntime({ device, positionals, @@ -185,6 +179,7 @@ async function runBoundSessionRoute( bindDevice, }); if (!bound.ok) return { ok: false, response: bound.response }; + const session = ref ? sessionStore.requireCurrent(ref) : undefined; const dispatchContext = { ...contextFromFlags(logPath, req.flags, session?.appBundleId, session?.trace?.outPath), surface: session?.surface, @@ -217,19 +212,16 @@ export async function handleAppEventCommand( ...params, command: PUBLIC_COMMANDS.triggerAppEvent, resolveRuntime: (runtimeParams) => resolveBoundAppEventRuntime(runtimeParams), - deriveNextSession: async (session, result) => { + updateSession: async (ref, result) => { const eventUrl = typeof result?.eventUrl === 'string' ? result.eventUrl : undefined; - const nextAppBundleId = eventUrl - ? ((await resolveSessionAppBundleIdForTarget( - session.device, - eventUrl, - session.appBundleId, - )) ?? session.appBundleId) - : session.appBundleId; - return { - ...session, - appBundleId: nextAppBundleId, - }; + if (!eventUrl) return; + const session = params.sessionStore.requireCurrent(ref); + const appBundleId = await resolveSessionAppBundleIdForTarget( + session.device, + eventUrl, + session.appBundleId, + ); + if (appBundleId !== undefined) params.sessionStore.update(ref, { appBundleId }); }, }); } diff --git a/src/daemon/handlers/snapshot-alert.ts b/src/daemon/handlers/snapshot-alert.ts index 14c10d4507..858bf36405 100644 --- a/src/daemon/handlers/snapshot-alert.ts +++ b/src/daemon/handlers/snapshot-alert.ts @@ -13,7 +13,7 @@ import { import type { DeviceInfo } from '@agent-device/kernel/device'; import { contextFromFlags } 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 { SessionStore } from '../session-store.ts'; import { recordIfSession } from '../snapshot-session.ts'; import { parseTimeout } from '@agent-device/command-registry/parse-timeout'; @@ -28,7 +28,7 @@ type HandleAlertCommandParams = { req: DaemonRequest; logPath: string; sessionStore: SessionStore; - session: SessionState | undefined; + ref: SessionRef | undefined; device: SessionState['device']; inspectFacts?: InspectDeviceRuntimeFacts; bindDevice?: BindDeviceRuntime; @@ -118,7 +118,8 @@ async function executeDismissAlert( export async function handleAlertCommand( params: HandleAlertCommandParams, ): Promise { - const { req, logPath, sessionStore, session, device, inspectFacts, bindDevice } = params; + const { req, logPath, sessionStore, ref, device, inspectFacts, bindDevice } = params; + let session = ref ? sessionStore.requireCurrent(ref) : undefined; const action = normalizeAlertAction(req.positionals?.[0]); const bound = await resolveBoundAlertRuntime({ device, @@ -128,6 +129,7 @@ export async function handleAlertCommand( bindDevice, }); if (!bound.ok) return bound.response; + session = ref ? sessionStore.requireCurrent(ref) : undefined; // ADR 0014 side-effect seam: alert accept/dismiss act on the device; get/wait are read-only. // The alert resolver returns `may-invalidate` only for the acting subactions, so this covers // the accept/dismiss mutations on every owner without touching the read paths. @@ -145,7 +147,7 @@ export async function handleAlertCommand( ...alertTarget(session), execution: runtimeExecutionFromContext(context), }); - recordIfSession(sessionStore, session, req, data); + recordIfSession(sessionStore, ref, req, data); return { ok: true, data }; } diff --git a/src/daemon/handlers/snapshot-settings.ts b/src/daemon/handlers/snapshot-settings.ts index a74a8b6319..e2d9ba450a 100644 --- a/src/daemon/handlers/snapshot-settings.ts +++ b/src/daemon/handlers/snapshot-settings.ts @@ -26,7 +26,7 @@ import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtim import { contextFromFlags } from '../context.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 { recordIfSession } from '../snapshot-session.ts'; import { expireRefFrame } from '../ref-frame.ts'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; @@ -62,7 +62,7 @@ type HandleSettingsCommandParams = { req: DaemonRequest; logPath: string; sessionStore: SessionStore; - session: SessionState | undefined; + ref: SessionRef | undefined; device: SessionState['device']; parsed: ParsedSettingsRequest; inspectFacts?: InspectDeviceRuntimeFacts; @@ -217,7 +217,8 @@ async function executeSettingsRead( params: HandleSettingsCommandParams, setting: ReadableSetting, ): Promise { - const { req, logPath, sessionStore, session, device, inspectFacts, bindDevice } = params; + const { req, logPath, sessionStore, ref, device, inspectFacts, bindDevice } = params; + let session = ref ? sessionStore.requireCurrent(ref) : undefined; const refusal = settingsRequestRefusal(device, setting); if (refusal !== undefined) return refusal; const admission = await admitRuntimeUse({ @@ -230,6 +231,8 @@ async function executeSettingsRead( }); if (admission.type === 'response') return admission.response; + session = ref ? sessionStore.requireCurrent(ref) : undefined; + emitDiagnostic({ level: 'debug', phase: 'settings_read', @@ -249,7 +252,7 @@ async function executeSettingsRead( ...payload, ...successText(describeSettingRead(payload)), }; - recordIfSession(sessionStore, session, req, data); + recordIfSession(sessionStore, ref, req, data); return { ok: true, data }; } @@ -257,7 +260,8 @@ async function executeSettingsWrite( params: HandleSettingsCommandParams, parsed: ParsedSettingsArgs, ): Promise { - const { req, logPath, sessionStore, session, device, inspectFacts, bindDevice } = params; + const { req, logPath, sessionStore, ref, device, inspectFacts, bindDevice } = params; + let session = ref ? sessionStore.requireCurrent(ref) : undefined; const { setting, state } = parsed; const refusal = settingsRequestRefusal(device, setting); if (refusal !== undefined) return refusal; @@ -269,8 +273,9 @@ async function executeSettingsWrite( bindDevice, readiness: !session, }); - const appBundleId = settingsWriteAppId(req, parsed, session); if (admission.type === 'response') return admission.response; + session = ref ? sessionStore.requireCurrent(ref) : undefined; + const appBundleId = settingsWriteAppId(req, parsed, session); const writeRefusal = settingsWriteRefusal(parsed, appBundleId); if (writeRefusal !== undefined) return writeRefusal; // ADR 0014 side-effect seam: a settings mutation changes device state; expire the frame before @@ -292,7 +297,7 @@ async function executeSettingsWrite( ), describeSettingWrite(setting, state, appBundleId), ); - recordIfSession(sessionStore, session, req, data); + recordIfSession(sessionStore, ref, req, data); return { ok: true, data }; } diff --git a/src/daemon/handlers/snapshot.ts b/src/daemon/handlers/snapshot.ts index 67d5340d73..62b63cb1f9 100644 --- a/src/daemon/handlers/snapshot.ts +++ b/src/daemon/handlers/snapshot.ts @@ -90,7 +90,11 @@ const SNAPSHOT_COMMAND_HANDLER_IMPLS = { bindDevice, platformResourceCleanup, }) => { - const { session, device } = await resolveSessionDevice(sessionStore, sessionName, req.flags); + const { ref, session, device } = await resolveSessionDevice( + sessionStore, + sessionName, + req.flags, + ); return await withSessionlessRunnerCleanup( session, device, @@ -99,7 +103,7 @@ const SNAPSHOT_COMMAND_HANDLER_IMPLS = { req, logPath, sessionStore, - session, + ref, device, inspectFacts, bindDevice, @@ -119,7 +123,11 @@ const SNAPSHOT_COMMAND_HANDLER_IMPLS = { }) => { const parsedSettings = parseSettingsArgs(req); if (!parsedSettings.ok) return parsedSettings; - const { session, device } = await resolveSessionDevice(sessionStore, sessionName, req.flags); + const { ref, session, device } = await resolveSessionDevice( + sessionStore, + sessionName, + req.flags, + ); return await withSessionlessRunnerCleanup( session, device, @@ -128,7 +136,7 @@ const SNAPSHOT_COMMAND_HANDLER_IMPLS = { req, logPath, sessionStore, - session, + ref, device, parsed: parsedSettings.parsed, inspectFacts, diff --git a/src/daemon/handlers/trace-runtime.ts b/src/daemon/handlers/trace-runtime.ts index 43b8ee86c6..01d6486c61 100644 --- a/src/daemon/handlers/trace-runtime.ts +++ b/src/daemon/handlers/trace-runtime.ts @@ -1,9 +1,10 @@ +import { expandSessionPath } from '@agent-device/host-kit/session-paths'; import fs from 'node:fs'; import path from 'node:path'; import type { TraceCommandResult } from '@agent-device/contracts/recording'; -import { SessionStore } from '../session-store.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 } from '../session-state.ts'; import { recordSessionAction } from '../session-action-recorder.ts'; import { errorResponse } from '@agent-device/kernel/contracts'; @@ -16,26 +17,25 @@ export function handleTraceCommand(params: { if (action !== 'start' && action !== 'stop') { return errorResponse('INVALID_ARGS', 'trace requires start|stop'); } - const session = params.sessionStore.get(params.sessionName); - if (!session) return errorResponse('SESSION_NOT_FOUND', 'No active session'); + const ref = params.sessionStore.lookup(params.sessionName); + if (!ref) return errorResponse('SESSION_NOT_FOUND', 'No active session'); return action === 'start' - ? startTrace(params.req, params.sessionStore, session) - : stopTrace(params.req, params.sessionStore, session); + ? startTrace(params.req, params.sessionStore, ref) + : stopTrace(params.req, params.sessionStore, ref); } function startTrace( req: DaemonRequest, sessionStore: SessionStore, - session: SessionState, + ref: SessionRef, ): DaemonResponse { + const session = sessionStore.requireCurrent(ref); if (session.trace) return errorResponse('INVALID_ARGS', 'trace already in progress'); - const outPath = SessionStore.expandHome( - req.positionals?.[1] ?? sessionStore.defaultTracePath(session), - ); + const outPath = expandSessionPath(req.positionals?.[1] ?? sessionStore.defaultTracePath(session)); fs.mkdirSync(path.dirname(outPath), { recursive: true }); fs.appendFileSync(outPath, ''); session.trace = { outPath, startedAt: Date.now() }; - recordSessionAction(sessionStore, session, req, req.command, { action: 'start', outPath }); + recordSessionAction(sessionStore, ref, req, req.command, { action: 'start', outPath }); return { ok: true, data: { trace: 'started', outPath } satisfies TraceCommandResult, @@ -45,12 +45,13 @@ function startTrace( function stopTrace( req: DaemonRequest, sessionStore: SessionStore, - session: SessionState, + ref: SessionRef, ): DaemonResponse { + const session = sessionStore.requireCurrent(ref); if (!session.trace) return errorResponse('INVALID_ARGS', 'no active trace'); const outPath = relocateTraceOutput(session.trace.outPath, req.positionals?.[1]); session.trace = undefined; - recordSessionAction(sessionStore, session, req, req.command, { action: 'stop', outPath }); + recordSessionAction(sessionStore, ref, req, req.command, { action: 'stop', outPath }); const clientOutPath = req.meta?.clientArtifactPaths?.outPath ?? outPath; return { ok: true, @@ -72,7 +73,7 @@ function stopTrace( function relocateTraceOutput(currentPath: string, requestedPath: string | undefined): string { if (!requestedPath) return currentPath; - const resolved = SessionStore.expandHome(requestedPath); + const resolved = expandSessionPath(requestedPath); fs.mkdirSync(path.dirname(resolved), { recursive: true }); if (fs.existsSync(currentPath)) fs.renameSync(currentPath, resolved); else fs.appendFileSync(resolved, ''); 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__/find-production-route.test.ts b/src/daemon/interaction/internal/__tests__/find-production-route.test.ts index dd39762ca3..35adfffcda 100644 --- a/src/daemon/interaction/internal/__tests__/find-production-route.test.ts +++ b/src/daemon/interaction/internal/__tests__/find-production-route.test.ts @@ -93,7 +93,7 @@ async function runFindThroughLeaf(options: { const sessionStore = makeSessionStore(); const sessionName = 'default'; const session = makeSession(sessionName); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockDispatch.mockImplementation(async (_device, command) => command === 'snapshot' ? { nodes: freshFindTree(), backend: 'xctest' } : {}, @@ -110,7 +110,6 @@ async function runFindThroughLeaf(options: { const current = sessionStore.get(sessionName); if (current) { current.snapshot = divergedSessionTree(); - sessionStore.set(sessionName, current); } } // The real leaf, not a stub. diff --git a/src/daemon/interaction/internal/__tests__/find-read-only.test.ts b/src/daemon/interaction/internal/__tests__/find-read-only.test.ts index 49e2fd8dd1..dad3650ff6 100644 --- a/src/daemon/interaction/internal/__tests__/find-read-only.test.ts +++ b/src/daemon/interaction/internal/__tests__/find-read-only.test.ts @@ -175,7 +175,7 @@ test('read-only find while recording is intentionally deferred from target-v1 ev const sessionStore = makeSessionStore(); const sessionName = 'default'; const session = makeAuthoringSession(sessionName); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockDispatch.mockImplementation(async (_device, command) => { if (command === 'snapshot') { return { diff --git a/src/daemon/interaction/internal/__tests__/find-single-bind.test.ts b/src/daemon/interaction/internal/__tests__/find-single-bind.test.ts index 51f4707d27..1acf192fae 100644 --- a/src/daemon/interaction/internal/__tests__/find-single-bind.test.ts +++ b/src/daemon/interaction/internal/__tests__/find-single-bind.test.ts @@ -25,7 +25,7 @@ async function runMutatingFind(positionals: string[], node: Record command === 'snapshot' ? { nodes: [node] } : {}, ); diff --git a/src/daemon/interaction/internal/__tests__/find-target-activation-disclosure.test.ts b/src/daemon/interaction/internal/__tests__/find-target-activation-disclosure.test.ts index c795e37258..2ed86a118b 100644 --- a/src/daemon/interaction/internal/__tests__/find-target-activation-disclosure.test.ts +++ b/src/daemon/interaction/internal/__tests__/find-target-activation-disclosure.test.ts @@ -80,7 +80,7 @@ async function findClick(captures: Record[] | CaptureScript, af const session = makeIosSession('default', { appBundleId: 'com.example.app' }); if (afterScroll) markDeferredInteractionOutcome({ session, command: 'scroll', positionals: [], flags: {} }); - sessionStore.set('default', session); + sessionStore.publish('default', session); let call = 0; legacyDispatchCapture.mockImplementation( async (_device, _command, _positionals, _out, context) => diff --git a/src/daemon/interaction/internal/__tests__/find-touch-runtime-fixture.ts b/src/daemon/interaction/internal/__tests__/find-touch-runtime-fixture.ts index 987c6e70f5..fc71d14823 100644 --- a/src/daemon/interaction/internal/__tests__/find-touch-runtime-fixture.ts +++ b/src/daemon/interaction/internal/__tests__/find-touch-runtime-fixture.ts @@ -70,7 +70,7 @@ export async function runFindClickScenario(options: { const sessionStore = makeSessionStore(); const sessionName = 'default'; const session = options.session ?? makeSession(sessionName); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); if (options.nodes !== undefined) { mockDispatch.mockImplementation(async (_device, command) => { diff --git a/src/daemon/interaction/internal/__tests__/interaction-capture-disclosure.test.ts b/src/daemon/interaction/internal/__tests__/interaction-capture-disclosure.test.ts index 03c0e94f7d..93b7160a79 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-capture-disclosure.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-capture-disclosure.test.ts @@ -55,7 +55,7 @@ async function pressSelector(params: { captureCalls: { count: number }; }) { const sessionStore = makeSessionStore(); - sessionStore.set(params.sessionName, makeSession(params.sessionName)); + sessionStore.publish(params.sessionName, makeSession(params.sessionName)); const response = await handleInteractionCommands({ req: { token: 't', @@ -106,7 +106,7 @@ test('a press that consumes no capture is not disclosed against an older tree', const sessionStore = makeSessionStore(); const session = makeSession(sessionName); session.snapshot = capturedTree({ sessionName, targetActivation: FACT }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await handleInteractionCommands({ req: { @@ -144,7 +144,7 @@ async function pressAfterUnsettledScroll(selector: string) { const sessionStore = makeSessionStore(); const session = makeSession('default'); markDeferredInteractionOutcome({ session, command: 'scroll', positionals: ['down'], flags: {} }); - sessionStore.set('default', session); + sessionStore.publish('default', session); let call = 0; legacyDispatchCapture.mockImplementation(async () => { call += 1; diff --git a/src/daemon/interaction/internal/__tests__/interaction-common.test.ts b/src/daemon/interaction/internal/__tests__/interaction-common.test.ts index 446a27be37..3835266ac0 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-common.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-common.test.ts @@ -38,7 +38,7 @@ test('parameterized fill scrubs backend and nested settle echoes at the response const placeholder = '${PASSWORD}'; const sessionStore = makeSessionStore(); const session = makeAuthoringSession('parameterized-fill'); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const payload = { text: secret, message: `prefix${secret}suffix`, @@ -78,7 +78,7 @@ test('parameterized fill scrubs backend and nested settle echoes at the response }; const response = finalizeTouchInteraction({ - session, + ref: sessionStore.lookup(session.name)!, sessionStore, command: 'fill', positionals: ['id="password"', secret], @@ -148,7 +148,7 @@ test('parameterized fill scrubs concatenated backend values and object keys thro createdAt: Date.now(), backend: 'xctest', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockImplementation(async (_device, command) => command === 'fill' ? { @@ -209,7 +209,7 @@ test('parameterized fill collapses whitespace-only backend echoes through the ha createdAt: Date.now(), backend: 'xctest', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockResolvedValue({ [`prefix${secret}suffix`]: { [`key${secret}tail`]: `value${secret}tail`, @@ -255,7 +255,7 @@ test.each([ const placeholder = `\${${recordAs}}`; const sessionStore = makeSessionStore(); const session = makeAuthoringSession(`parameterized-overlap-${recordAs}`); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const payload = { text: literal, refLabel: `prefix${literal}suffix`, @@ -266,7 +266,7 @@ test.each([ }; const response = finalizeTouchInteraction({ - session, + ref: sessionStore.lookup(session.name)!, sessionStore, command: 'fill', positionals: ['id="password"', literal], diff --git a/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts b/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts index 70092d2dff..8e11181547 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts @@ -87,7 +87,7 @@ async function press({ createdAt: Date.now(), backend: 'xctest', }; - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const response = await routeInteraction({ req: { token: 't', session: session.name, command, positionals, flags }, sessionName: session.name, @@ -184,7 +184,7 @@ async function swipeRefusedOnSecondRepetition(): Promise { } const sessionStore = makeSessionStore(); const session = makeSession('dispatch-disclosure-swipe'); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const response = await routeInteraction({ req: { token: 't', @@ -216,7 +216,7 @@ async function pressThenForegroundReadRefused(): Promise { appBundleId: 'com.example.app', }); const sessionStore = makeSessionStore(); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const readRefusal = new AppError('COMMAND_FAILED', 'adb device offline', { dispatched: 'no' }); const androidObservation: AndroidObservationAdapter = { ...clearAndroidObservationFixture, @@ -325,7 +325,7 @@ test('a read-only command discloses no over a producer verdict it throws', async test('a plain Error a backend throws reaches the wire with dispatched unknown', async () => { const sessionStore = makeSessionStore(); const session = makeSession('dispatch-disclosure-plain-error'); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const bindings = getRuntimeBindings(); const bindDevice = vi.fn(async () => { throw new Error('socket hang up'); 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..79bc1e2b24 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(() => { @@ -89,7 +89,7 @@ test('recorded ref drag dispatches once and stores portable selectors with both const sessionStore = makeSessionStore(); const sessionName = 'recorded-drag'; const session = makeDragSession(sessionName); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await runDrag(sessionStore, sessionName); @@ -140,7 +140,7 @@ test('recorded ref drag dispatches once and stores portable selectors with both test('a second ref drag is rejected before dispatch after the first drag expires the frame', async () => { const sessionStore = makeSessionStore(); const sessionName = 'stale-drag'; - sessionStore.set(sessionName, makeDragSession(sessionName)); + sessionStore.publish(sessionName, makeDragSession(sessionName)); expect((await runDrag(sessionStore, sessionName))?.ok).toBe(true); const response = await runDrag(sessionStore, sessionName); diff --git a/src/daemon/interaction/internal/__tests__/interaction-get.test.ts b/src/daemon/interaction/internal/__tests__/interaction-get.test.ts index 0b287b4cf7..9b35dba6fc 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-get.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-get.test.ts @@ -92,7 +92,7 @@ test('get text prefers underlying value for text surfaces and avoids recording g backend: 'xctest', producer: 'apple-runner', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockRejectedValue( new Error('dispatch should not be called for snapshot-derived get text'), @@ -143,7 +143,7 @@ test('get text uses backend read expansion when the resolved node has a rect', a backend: 'xctest', producer: 'apple-runner', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockReadTextAtPoint.mockResolvedValue( elementTextRead('package com.example.app\nclass MainActivity {}'), @@ -192,7 +192,7 @@ test('get text answers from the captured tree when the bound owner advertises no backend: 'xctest', producer: 'apple-runner', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); elementReadFixtureState.readTextAtPointAvailable = false; const response = await handleInteractionCommands({ @@ -226,7 +226,10 @@ test('get text answers from the captured tree when the bound owner advertises no test('an eligible direct iOS selector cannot operate before admission', async () => { const sessionStore = makeSessionStore(); const sessionName = 'get-text-direct-before-admission'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); elementReadFixtureState.captureSnapshotAvailable = false; mockRunAppleRunnerCommand.mockResolvedValue({ found: true, @@ -262,7 +265,10 @@ test('an eligible direct iOS selector cannot operate before admission', async () test('get text simple iOS id selector resolves through the bound capture, not a runner query', async () => { const sessionStore = makeSessionStore(); const sessionName = 'get-text-ios-direct-selector'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); legacyDispatchCapture.mockResolvedValue({ backend: 'xctest', producer: 'apple-runner', @@ -320,7 +326,10 @@ test('get text simple iOS id selector resolves through the bound capture, not a test('get text iOS label selector uses snapshot disambiguation instead of runner query', async () => { const sessionStore = makeSessionStore(); const sessionName = 'get-text-ios-label-selector'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); legacyDispatchCapture.mockResolvedValue({ backend: 'xctest', producer: 'apple-runner', @@ -393,7 +402,7 @@ test('get text iOS label selector uses snapshot disambiguation instead of runner test('is visible preserves CLI snapshot flags during runtime snapshot capture', async () => { const sessionStore = makeSessionStore(); const sessionName = 'snapshot-flags'; - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command !== 'snapshot') throw new Error(`unexpected command: ${command}`); @@ -456,7 +465,7 @@ test('is visible reuses fresh cached iOS snapshots with rects', async () => { backend: 'xctest', producer: 'apple-runner', }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockRejectedValue(new Error('unexpected fresh snapshot')); const response = await handleInteractionCommands({ @@ -490,7 +499,7 @@ test('is visible recaptures web snapshots when cached nodes may lack rects', asy }, { snapshotInteractiveOnly: false }, ); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockResolvedValue( makeVisibleButtonSnapshot('Submit order', { backend: 'web', @@ -528,7 +537,10 @@ test('is visible recaptures web snapshots when cached nodes may lack rects', asy test('a failing is predicate is COMMAND_FAILED, never a zero-exit pass', async () => { const sessionStore = makeSessionStore(); const sessionName = 'is-selected-ios-direct-selector-false'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); mockRunAppleRunnerCommand.mockResolvedValue({ found: true, nodes: [ @@ -570,7 +582,7 @@ test('a failing is predicate is COMMAND_FAILED, never a zero-exit pass', async ( test('is visible passes for list text that inherits viewport visibility from an ancestor', async () => { const sessionStore = makeSessionStore(); const sessionName = 'visible-list-item'; - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command !== 'snapshot') throw new Error(`unexpected command: ${command}`); @@ -623,7 +635,7 @@ test('is visible passes for list text that inherits viewport visibility from an test('is visible fails for nodes outside the current viewport', async () => { const sessionStore = makeSessionStore(); const sessionName = 'visible-offscreen'; - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command !== 'snapshot') throw new Error(`unexpected command: ${command}`); @@ -669,7 +681,7 @@ test('is visible fails for nodes outside the current viewport', async () => { test('is reports Android permission dialog blocker when app content assertion fails', async () => { const sessionStore = makeSessionStore(); const sessionName = 'android-permission-blocked'; - sessionStore.set( + sessionStore.publish( sessionName, makeBaseAndroidSession(sessionName, { appBundleId: 'com.example.demo' }), ); @@ -734,7 +746,7 @@ test('ADR 0014 evidence #17: get text @ref reads the retained frame tree, not a backend: 'xctest', producer: 'apple-runner', }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockRejectedValue(new Error('get text @ref must not recapture')); // Resolves against the frame tree's @e2 (Continue), never the observation's @@ -764,7 +776,7 @@ test('get text @ref warns while the frame is expired (retained evidence still re // A device action expired the frame; the read still resolves against the // retained frame tree and stays fail-open with a warning (ADR 0014). expireRefFrame(session); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockRejectedValue( new Error('dispatch should not be called for snapshot-derived get text'), ); @@ -782,7 +794,7 @@ test('get text with a pinned stale ref gets the precise warning', async () => { const sessionName = 'pinned-get-text'; const session = makeStaleRefSession(sessionName); session.snapshotGeneration = 4; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockRejectedValue( new Error('dispatch should not be called for snapshot-derived get text'), ); 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..95827268d6 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 @@ -109,7 +109,7 @@ test('a changed post-action capture corroborates an iOS tap reported as failed', appBundleId: 'com.example.app', snapshot: snapshot(profileNodes), }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'press') { throw new AppError( @@ -139,7 +139,7 @@ test('an unchanged post-action capture keeps a failed iOS tap failed', async () appBundleId: 'com.example.app', snapshot: snapshot(profileNodes), }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'press') { throw new AppError( @@ -165,7 +165,7 @@ test('a private-ax baseline pins the corroboration probe to private-ax', async ( // correctly refuses to compare, and a landed tap surfaces as a failure. const sessionName = 'ios-private-ax-pinned-corroboration'; const sessionStore = makeSessionStore(); - sessionStore.set( + sessionStore.publish( sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app', @@ -206,7 +206,7 @@ test('a canonical selector capture replaces a raw baseline before corroboration' // capture at all) and the recorded failure surfaces unchanged. const sessionName = 'ios-raw-baseline-no-corroboration'; const sessionStore = makeSessionStore(); - sessionStore.set( + sessionStore.publish( sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app', @@ -233,7 +233,7 @@ test('a canonical selector capture replaces a raw baseline before corroboration' test('a tree baseline does not pin the corroboration probe backend', async () => { const sessionName = 'ios-tree-baseline-unpinned-corroboration'; const sessionStore = makeSessionStore(); - sessionStore.set( + sessionStore.publish( sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app', @@ -267,7 +267,7 @@ test('a tree baseline does not pin the corroboration probe backend', async () => test('a changed capture from a different iOS backend keeps the tap failure', async () => { const sessionName = 'ios-cross-backend-tap-corroboration'; const sessionStore = makeSessionStore(); - sessionStore.set( + sessionStore.publish( sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app', @@ -301,7 +301,7 @@ test('a producer or generation switch cannot corroborate a failed tap', async () appBundleId: 'com.example.app', snapshot: baseline, }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const after = snapshot(imageViewerNodes); after.comparisonKey = 'apple-runner:launch-a'; @@ -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, @@ -332,7 +332,7 @@ test('a capture of a system surface cannot corroborate a tap taken against the a appBundleId: 'com.example.app', snapshot: baseline, }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const after = snapshot(imageViewerNodes); after.comparisonKey = systemSurfaceCaptureComparisonKey(IOS_SIMULATOR.id); after.iosSystemSurfaceBundleId = 'com.apple.SafariViewService'; @@ -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, @@ -354,7 +354,7 @@ test('a capture of a system surface cannot corroborate a tap taken against the a test('a sparse changed capture keeps the tap failure', async () => { const sessionName = 'ios-sparse-tap-corroboration'; const sessionStore = makeSessionStore(); - sessionStore.set( + sessionStore.publish( sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app', @@ -387,7 +387,7 @@ test('a sparse changed capture keeps the tap failure', async () => { test('a corroboration capture failure keeps the tap failure', async () => { const sessionName = 'ios-capture-failed-tap-corroboration'; const sessionStore = makeSessionStore(); - sessionStore.set( + sessionStore.publish( sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app', @@ -417,7 +417,7 @@ test('the canonical selector capture aligns presentation before corroboration', const sessionStore = makeSessionStore(); const baseline = snapshot(profileNodes); baseline.presentationKey = 'unreadable-presentation'; - sessionStore.set( + sessionStore.publish( sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app', @@ -446,7 +446,7 @@ test('corroborates a tap when the request carries no flags and the baseline used const sessionStore = makeSessionStore(); const baseline = snapshot(profileNodes); baseline.presentationKey = buildSnapshotPresentationKey({ depth: 2, raw: false }); - sessionStore.set( + sessionStore.publish( sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app', @@ -497,7 +497,7 @@ test('a changed capture after ordinary agent turn latency still corroborates the const sessionStore = makeSessionStore(); const baseline = snapshot(profileNodes); baseline.createdAt = Date.now() - 6_000; - sessionStore.set( + sessionStore.publish( sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app', @@ -530,7 +530,7 @@ test('the canonical selector capture replaces a stale baseline before corroborat const sessionStore = makeSessionStore(); const baseline = snapshot(profileNodes); baseline.createdAt = Date.now() - 15_001; - sessionStore.set( + sessionStore.publish( sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app', @@ -560,7 +560,7 @@ test('the canonical selector capture replaces a keyless baseline before corrobor const sessionStore = makeSessionStore(); const baseline = snapshot(profileNodes); baseline.presentationKey = undefined; - sessionStore.set( + sessionStore.publish( sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app', @@ -588,7 +588,7 @@ test('the canonical selector capture replaces a keyless baseline before corrobor test('runtime-resolved taps use the same corroboration boundary', async () => { const sessionName = 'ios-runtime-tap-corroboration'; const sessionStore = makeSessionStore(); - sessionStore.set( + sessionStore.publish( sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app', @@ -622,7 +622,7 @@ test('runtime-resolved taps use the same corroboration boundary', async () => { test('runtime coordinate taps use the same corroboration boundary', async () => { const sessionName = 'ios-runtime-coordinate-corroboration'; const sessionStore = makeSessionStore(); - sessionStore.set( + sessionStore.publish( sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app', @@ -654,7 +654,7 @@ test('corroborated runtime taps retain target evidence through save and replay', const root = mkdtempForTestSync('agent-device-ios-tap-replay-'); const sessionName = 'ios-recorded-tap'; const sessionStore = makeSessionStore(); - sessionStore.set( + sessionStore.publish( sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app', @@ -707,7 +707,7 @@ test('corroborated runtime taps retain target evidence through save and replay', recording = false; const replaySessionName = 'ios-replayed-tap'; const replayStore = makeSessionStore(); - replayStore.set( + replayStore.publish( replaySessionName, makeIosSession(replaySessionName, { appBundleId: 'com.example.app', @@ -745,3 +745,32 @@ test('corroborated runtime taps retain target evidence through save and replay', expect(pressCount).toBe(2); expect(snapshotCount).toBeGreaterThanOrEqual(4); }); + +test('a tap corroborates against its admitted snapshot after a same-lifetime record rebuild', async () => { + const store = makeSessionStore(); + const name = 'ios-admitted-tap-baseline'; + const ref = store.publish( + name, + makeIosSession(name, { + appBundleId: 'com.example.app', + snapshot: snapshot(imageViewerNodes), + }), + ); + store.update(ref, { snapshot: snapshot(profileNodes) }); + legacyDispatchCapture.mockImplementation(async (_device, command) => { + if (command === 'press') throw new AppError('XCTEST_RECORDED_FAILURE', 'tap failed'); + if (command === 'snapshot') return snapshotPayload(imageViewerNodes); + return {}; + }); + const response = await handleInteractionCommands({ + req: { token: 'test', session: name, command: 'click', positionals: ['104', '222'], flags: {} }, + sessionName: name, + sessionRef: ref, + sessionStore: store, + contextFromFlags, + ...getRuntimeBindings(), + }); + expect(response?.ok).toBe(true); + if (!response?.ok) throw new Error('landed tap should be corroborated'); + expect(response.data?.warning).toMatch(/same-scope post-action accessibility capture changed/); +}); diff --git a/src/daemon/interaction/internal/__tests__/interaction-settle-private-ax-route.test.ts b/src/daemon/interaction/internal/__tests__/interaction-settle-private-ax-route.test.ts index f1846e7c16..97e062f404 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-settle-private-ax-route.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-settle-private-ax-route.test.ts @@ -61,7 +61,7 @@ beforeEach(() => { test('daemon press --settle pins private-ax on emitted snapshot runner requests', async () => { const sessionName = 'press-settle-private-ax-route'; const sessionStore = makeSessionStore(); - sessionStore.set( + sessionStore.publish( sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.fixture', diff --git a/src/daemon/interaction/internal/__tests__/interaction-settle.test.ts b/src/daemon/interaction/internal/__tests__/interaction-settle.test.ts index 66acb3966c..a8b8e4a7a1 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; } @@ -87,7 +97,7 @@ function seedSession(sessionName: string, sessionStore: ReturnType { }); 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 } }, { @@ -311,7 +381,6 @@ test('press --settle rejects an expired-frame ref before dispatch or observation const session = seedSession(sessionName, sessionStore); // ADR 0014: a device action since the snapshot expired the ref frame. expireRefFrame(session); - sessionStore.set(sessionName, session); legacyDispatchCapture.mockRejectedValue( new Error('dispatch should not be called for an expired-frame ref'), ); @@ -391,7 +460,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/__tests__/interaction-target-evidence.test.ts b/src/daemon/interaction/internal/__tests__/interaction-target-evidence.test.ts index 08409b9645..bfd8845d7b 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-target-evidence.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-target-evidence.test.ts @@ -101,7 +101,7 @@ async function runCommand( test('press @ref while recording attaches target-v1 evidence to the recorded action, never to the public response', async () => { const sessionStore = makeSessionStore(); const sessionName = 'recording-press'; - sessionStore.set(sessionName, makeSessionWithSnapshot(sessionName, { recording: true })); + sessionStore.publish(sessionName, makeSessionWithSnapshot(sessionName, { recording: true })); // verify:true forces full runtime resolution, which captures node/tree. const response = await runCommand(sessionStore, sessionName, 'press', ['@e1'], { verify: true }); @@ -131,7 +131,7 @@ test('press @ref while recording attaches target-v1 evidence to the recorded act test('press @ref without recording never computes target-v1 evidence', async () => { const sessionStore = makeSessionStore(); const sessionName = 'non-recording-press'; - sessionStore.set(sessionName, makeSessionWithSnapshot(sessionName, { recording: false })); + sessionStore.publish(sessionName, makeSessionWithSnapshot(sessionName, { recording: false })); const response = await runCommand(sessionStore, sessionName, 'press', ['@e1'], { verify: true }); @@ -145,7 +145,7 @@ test('press @ref without recording never computes target-v1 evidence', async () test('get text @ref while recording attaches target-v1 evidence to the recorded action, never to session history payloads', async () => { const sessionStore = makeSessionStore(); const sessionName = 'recording-get-ref'; - sessionStore.set(sessionName, makeSessionWithSnapshot(sessionName, { recording: true })); + sessionStore.publish(sessionName, makeSessionWithSnapshot(sessionName, { recording: true })); const response = await runCommand(sessionStore, sessionName, 'get', ['text', '@e1']); @@ -173,7 +173,7 @@ test('get text @ref while recording attaches target-v1 evidence to the recorded test('get text @ref without recording never computes target-v1 evidence', async () => { const sessionStore = makeSessionStore(); const sessionName = 'non-recording-get-ref'; - sessionStore.set(sessionName, makeSessionWithSnapshot(sessionName, { recording: false })); + sessionStore.publish(sessionName, makeSessionWithSnapshot(sessionName, { recording: false })); const response = await runCommand(sessionStore, sessionName, 'get', ['text', '@e1']); @@ -186,7 +186,7 @@ test('get text simple iOS id selector while recording skips the direct runner qu const sessionStore = makeSessionStore(); const sessionName = 'recording-get-direct-gate'; const session = makeAuthoringSession(sessionName, { appBundleId: 'com.example.app' }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'snapshot') { return { @@ -325,7 +325,7 @@ test('press on an identity-empty container: container-based daemon response, des // recording) without Android's real-adb dialog-readiness probes, which // would burn wall-clock time this unit lane must not spend. const session = makeAuthoringSession(sessionName, { appBundleId: 'com.example.app' }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'snapshot') { return { backend: 'xctest', nodes: IDENTITY_EMPTY_ROW_NODES }; @@ -370,7 +370,7 @@ function mockSnapshotWithSaveButton() { test('is visible while recording attaches target-v1 evidence and strips the resolution payload everywhere', async () => { const sessionStore = makeSessionStore(); const sessionName = 'recording-is'; - sessionStore.set(sessionName, makeSessionWithSnapshot(sessionName, { recording: true })); + sessionStore.publish(sessionName, makeSessionWithSnapshot(sessionName, { recording: true })); mockSnapshotWithSaveButton(); const response = await runCommand(sessionStore, sessionName, 'is', ['visible', 'label="Save"']); @@ -402,7 +402,7 @@ test('is visible while recording attaches target-v1 evidence and strips the reso test('is exists while recording stays intentionally unannotated (deferred coverage)', async () => { const sessionStore = makeSessionStore(); const sessionName = 'recording-is-exists'; - sessionStore.set(sessionName, makeSessionWithSnapshot(sessionName, { recording: true })); + sessionStore.publish(sessionName, makeSessionWithSnapshot(sessionName, { recording: true })); mockSnapshotWithSaveButton(); const response = await runCommand(sessionStore, sessionName, 'is', ['exists', 'label="Save"']); @@ -416,7 +416,7 @@ test('is exists while recording stays intentionally unannotated (deferred covera test('is absent while recording stays an ordinary unannotated observation', async () => { const sessionStore = makeSessionStore(); const sessionName = 'recording-is-absent'; - sessionStore.set(sessionName, makeSessionWithSnapshot(sessionName, { recording: true })); + sessionStore.publish(sessionName, makeSessionWithSnapshot(sessionName, { recording: true })); mockSnapshotWithSaveButton(); const response = await runCommand(sessionStore, sessionName, 'is', [ @@ -447,7 +447,7 @@ test('is absent while recording stays an ordinary unannotated observation', asyn test('is visible without recording never computes target-v1 evidence', async () => { const sessionStore = makeSessionStore(); const sessionName = 'non-recording-is'; - sessionStore.set(sessionName, makeSessionWithSnapshot(sessionName, { recording: false })); + sessionStore.publish(sessionName, makeSessionWithSnapshot(sessionName, { recording: false })); mockSnapshotWithSaveButton(); const response = await runCommand(sessionStore, sessionName, 'is', ['visible', 'label="Save"']); diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-android-freshness.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-android-freshness.test.ts index 7a1296b902..70669107e3 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-android-freshness.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-android-freshness.test.ts @@ -87,7 +87,7 @@ test('press @ref refreshes Android snapshot when freshness tracking is active', baselineCount: 1, routeComparable: false, }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockCaptureSnapshotForSession.mockResolvedValue({ nodes: [ @@ -159,7 +159,7 @@ test('ADR 0014: Android freshness cannot retarget an admitted ref by positional baselineCount: 1, routeComparable: false, }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); // The freshness refresh returns a DIFFERENT element at @e1's index — after // navigation the button at that position is now "Cancel", not "Continue". @@ -224,7 +224,7 @@ test('press @ref falls back to cached Android ref when freshness refresh fails', baselineCount: 1, routeComparable: true, }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockCaptureSnapshotForSession.mockRejectedValueOnce(new Error('uiautomator timeout')); @@ -293,7 +293,7 @@ test('coordinate press preserves Android route freshness from last comparable sn backend: 'android', comparisonSafe: false, }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await handleInteractionCommands({ req: { diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-android-readiness.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-android-readiness.test.ts index 56642c945d..df7be26605 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-android-readiness.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-android-readiness.test.ts @@ -100,7 +100,7 @@ test('press @ref fails when Android tap escapes to launcher', async () => { createdAt: Date.now(), backend: 'android', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockTapPoint.mockResolvedValue({ pressed: true }); mockGetAndroidAppState.mockResolvedValue({ @@ -148,7 +148,7 @@ test('press @ref fails when Android tap escapes to Settings', async () => { createdAt: Date.now(), backend: 'android', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockTapPoint.mockResolvedValue({ pressed: true }); mockGetAndroidAppState.mockResolvedValue({ @@ -203,7 +203,7 @@ test.each(ANDROID_PERMISSION_PROMPT_PACKAGES)( createdAt: Date.now(), backend: 'android', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockTapPoint.mockResolvedValue({ pressed: true }); mockGetAndroidAppState.mockResolvedValue({ @@ -268,7 +268,7 @@ test('a ref action aborts with the shared ref_frame_expired rejection after Andr }; session.snapshotGeneration = 900; // A freshly issued complete frame is active. - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await handleInteractionCommands({ req: { token: 't', session: sessionName, command: 'press', positionals: ['@e1'], flags: {} }, diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios-eligibility.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios-eligibility.test.ts index ec1e54d5ad..53be62df8a 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios-eligibility.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios-eligibility.test.ts @@ -66,7 +66,10 @@ beforeEach(() => { test('ordinary click uses canonical capture even when the owner binds selector tap', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-selector-without-direct-operation'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); mockCaptureSnapshotForSession.mockResolvedValueOnce({ nodes: attachRefs([ { @@ -111,7 +114,10 @@ test('ordinary click uses canonical capture even when the owner binds selector t test('fill simple iOS id selector resolves runtime text input evidence before coordinate fill', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-runtime-selector-fill'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); mockCaptureSnapshotForSession.mockResolvedValueOnce({ nodes: attachRefs([ @@ -175,7 +181,10 @@ test('fill simple iOS id selector resolves runtime text input evidence before co test('click simple iOS selector forwards Maestro non-hittable coordinate fallback', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-maestro-selector-fallback'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); mockTapElementSelector.mockResolvedValue({ message: 'tapped via non-hittable coordinate fallback', @@ -223,7 +232,7 @@ test('click simple iOS id selector waits for snapshot path after pending gesture const sessionName = 'ios-direct-selector-after-swipe'; const session = makeIosSession(sessionName, { appBundleId: 'com.example.app' }); session.postGestureStabilization = { action: 'swipe', positionals: [], markedAt: Date.now() }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockCaptureSnapshotForSession.mockResolvedValue({ nodes: attachRefs([ diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios.test.ts index c1948270a4..b001eac25d 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios.test.ts @@ -52,7 +52,10 @@ test.each([ async (code, message) => { const sessionStore = makeSessionStore(); const sessionName = `ios-maestro-direct-selector-${code}`; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); mockTapElementSelector.mockRejectedValue(new AppError(code, message)); const response = await handleInteractionCommands({ @@ -77,7 +80,7 @@ test.each([ test('Maestro selector click crosses the ADR 0014 fused seam and expires the ref frame', async () => { const sessionStore = makeSessionStore(); const sessionName = 'maestro-direct-ios-seam'; - sessionStore.set(sessionName, makeStaleRefSession(sessionName)); + sessionStore.publish(sessionName, makeStaleRefSession(sessionName)); const click = await runInteraction(sessionStore, sessionName, 'click', ['label=Continue'], { maestro: { allowNonHittableCoordinateFallback: true }, @@ -121,7 +124,7 @@ for (const row of ROWS) { assert.equal(typeof row.fallsBack, 'boolean', `${row.id} must state fallsBack`); const sessionStore = makeSessionStore(); const sessionName = `maestro-direct-${row.id}`; - sessionStore.set(sessionName, makeStaleRefSession(sessionName)); + sessionStore.publish(sessionName, makeStaleRefSession(sessionName)); mockTapElementSelector.mockRejectedValueOnce(failure()); vi.mocked(captureSnapshotWithInteractor).mockResolvedValue({ nodes: makeTwoButtonNodes(), diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-fill.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-fill.test.ts index be1a3b3c49..905028089a 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-fill.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-fill.test.ts @@ -96,7 +96,7 @@ test('fill @ref fails fast when the target is off-screen', async () => { createdAt: Date.now(), backend: 'xctest', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await withRunner(() => handleInteractionCommands({ @@ -153,7 +153,7 @@ test('fill @ref fails closed when stored ref bounds are invalid (ADR 0014)', asy createdAt: Date.now(), backend: 'android', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockFillPoint.mockRejectedValue( new Error('dispatch must not run: no positional recapture on unusable frame evidence'), @@ -204,7 +204,7 @@ test('fill @ref rejects after a device action expired the frame', async () => { }; // ADR 0014: an unobserved device action expired the frame. expireRefFrame(session); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockFillPoint.mockRejectedValue( new Error('dispatch should not be called for an expired-frame ref'), ); @@ -244,7 +244,7 @@ test('fill with a pinned stale ref rejects; pinned current is clean', async () = session.snapshotGeneration = 3; // Re-issue the frame over the overridden snapshot so its source tree matches. activateCompleteRefFrame(session); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const stale = await runInteraction(sessionStore, sessionName, 'fill', ['@e1~s2', 'hello']); expect(stale?.ok).toBe(false); @@ -290,7 +290,7 @@ test("ADR 0014 blocker-2: a mutating find's internal fill from an expired frame // device side effect changed the screen. activateCompleteRefFrame(session); expireRefFrame(session); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockFillPoint.mockResolvedValue({ filled: true }); // Contrast: a user-supplied `@ref` against the expired frame rejects before @@ -355,7 +355,7 @@ test('fill @ref keeps the original editable node when its parent is the hittable createdAt: Date.now(), backend: 'xctest', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockFillPoint.mockResolvedValue({ filled: true }); diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-payload.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-payload.test.ts index d19e1bd7fe..3d424ce2d6 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-payload.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-payload.test.ts @@ -88,7 +88,7 @@ test('press coordinates appends touch-visualization events while recording', asy startedAt: Date.now() - 1_000, showTouches: true, }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockTapPoint.mockResolvedValue({ ok: true, @@ -139,7 +139,7 @@ test('press coordinates on iOS recording captures a full snapshot for the touch startedAt: Date.now() - 1_000, showTouches: true, }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockTapPoint.mockResolvedValue({ x: 220, y: 600 }); // Regression: a filtered snapshot has no Application/Window node, so viewport inference would @@ -198,7 +198,7 @@ test('press coordinates on Android recording uses physical screen size when no s showTouches: true, }); session.snapshot = undefined; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockTapPoint.mockResolvedValue({ x: 300, y: 2300 }); @@ -234,7 +234,7 @@ test('press coordinates on Android recording caches physical screen size across showTouches: true, }); session.snapshot = undefined; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockTapPoint.mockResolvedValue({ x: 300, y: 2300 }); @@ -281,7 +281,7 @@ test('press coordinates without recording skips Android screen-size lookup', asy const sessionName = 'android-direct-press-no-recording'; const session = makeAndroidSession(sessionName); session.snapshot = undefined; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockTapPoint.mockResolvedValue({ x: 300, y: 2300 }); @@ -314,7 +314,7 @@ test('press coordinates during recording still dispatches when Android screen-si showTouches: true, }); session.snapshot = undefined; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockTapPoint.mockResolvedValue({ x: 300, y: 2300 }); mockGetAndroidScreenSize.mockRejectedValue(new Error('adb unavailable')); @@ -368,7 +368,7 @@ test('press @ref preserves native timing in recorded result and touch visualizat startedAt: 1_000, showTouches: true, }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const originalNow = Date.now; let now = 1_500; diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-press-admission.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-press-admission.test.ts index ea0c48158c..9a855d8abb 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-press-admission.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-press-admission.test.ts @@ -85,7 +85,7 @@ test('click --button middle on macOS fails with an explicit unsupported-operatio kind: 'device', booted: true, }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockTapPoint.mockRejectedValue( new Error('dispatch should not be called for unsupported middle click'), @@ -116,7 +116,7 @@ test('click --button middle on macOS fails with an explicit unsupported-operatio test('press coordinates does not treat extra trailing args as selector', async () => { const sessionStore = makeSessionStore(); const sessionName = 'default'; - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); const response = await handleInteractionCommands({ req: { @@ -141,7 +141,7 @@ test('press coordinates does not treat extra trailing args as selector', async ( test('#1654: the resolved-target payload fails closed when its ref provenance disagrees', async () => { const sessionStore = makeSessionStore(); const sessionName = 'find-preresolved-mismatched-ref'; - sessionStore.set(sessionName, makeStaleRefSession(sessionName)); + sessionStore.publish(sessionName, makeStaleRefSession(sessionName)); const preresolved = makeFindPreresolvedTree(); const response = await runFindInternalClick(sessionStore, sessionName, { @@ -160,7 +160,7 @@ test('press selector then press @ref rejects refs that outlived the stored snaps const sessionStore = makeSessionStore(); const sessionName = 'stale-ref-warns'; const session = makeStaleRefSession(sessionName); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockCaptureSnapshotForSession.mockResolvedValue({ nodes: makeTwoButtonNodes(), backend: 'xctest', @@ -196,7 +196,7 @@ test('a ref press crosses the ADR 0014 side-effect seam and expires the ref fram const sessionStore = makeSessionStore(); const sessionName = 'seam-expiry'; const session = makeStaleRefSession(sessionName); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockCaptureSnapshotForSession.mockResolvedValue({ nodes: makeTwoButtonNodes(), backend: 'xctest', @@ -219,7 +219,7 @@ test('ADR 0014 evidence #1: a second ref mutation rejects (bare and pinned) unti session.snapshotGeneration = 500; // A complete snapshot issued the frame at generation 500. activateCompleteRefFrame(session); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockCaptureSnapshotForSession.mockResolvedValue({ nodes: makeTwoButtonNodes(), backend: 'xctest', @@ -251,7 +251,7 @@ test('press @ref directly after refs were issued does not warn', async () => { const sessionStore = makeSessionStore(); const sessionName = 'fresh-ref-no-warning'; const session = makeStaleRefSession(sessionName); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await runInteraction(sessionStore, sessionName, 'press', ['@e1']); expect(response?.ok).toBe(true); @@ -264,7 +264,7 @@ test('re-issuing a complete frame lets press @ref succeed again without warning' const sessionStore = makeSessionStore(); const sessionName = 'reissued-refs-no-warning'; const session = makeStaleRefSession(sessionName); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockCaptureSnapshotForSession.mockResolvedValue({ nodes: makeTwoButtonNodes(), backend: 'xctest', @@ -283,7 +283,6 @@ test('re-issuing a complete frame lets press @ref succeed again without warning' // snapshot-handler tests). Without it the frame would stay expired. const stored = sessionStore.get(sessionName)!; activateCompleteRefFrame(stored); - sessionStore.set(sessionName, stored); const refPress = await runInteraction(sessionStore, sessionName, 'press', ['@e1']); expect(refPress?.ok).toBe(true); @@ -299,7 +298,7 @@ test('press with a pinned ref matching the current frame epoch is clean', async const sessionName = 'pinned-current-clean'; const session = makeStaleRefSession(sessionName); session.snapshotGeneration = 5; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await runInteraction(sessionStore, sessionName, 'press', ['@e1~s5']); expect(response?.ok).toBe(true); @@ -313,7 +312,7 @@ test('press with a pinned ref from an older generation rejects with the precise const sessionName = 'pinned-stale-precise'; const session = makeStaleRefSession(sessionName); session.snapshotGeneration = 15; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockTapPoint.mockRejectedValue(new Error('touch should not be called for a stale iOS ref')); const response = await runInteraction(sessionStore, sessionName, 'press', ['@e1~s12']); @@ -343,7 +342,7 @@ test('ADR 0014 evidence #6: a read-only capture does not invalidate a mutation r backend: 'xctest', }); expect(refFrameState(session)).toBe('active'); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockTapPoint.mockResolvedValue({ pressed: true }); const response = await runInteraction(sessionStore, sessionName, 'press', ['@e1']); @@ -362,7 +361,7 @@ test('a malformed generation suffix is INVALID_ARGS with the ref grammar hint', const sessionStore = makeSessionStore(); const sessionName = 'pinned-malformed'; const session = makeStaleRefSession(sessionName); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); for (const [command, positionals] of [ ['press', ['@e1~x3']], @@ -392,7 +391,7 @@ test('after a session reopen, a pin from the previous lifetime rejects (reseeded // one replacement deep (a per-lifetime count from 1 would collide here). const reopened = makeStaleRefSession(sessionName); setSessionSnapshot(reopened, { ...reopened.snapshot! }); - sessionStore.set(sessionName, reopened); + sessionStore.publish(sessionName, reopened); // Probabilistic (~1/900000 collision) — accepted residual risk. expect(reopened.snapshotGeneration).not.toBe(oldGeneration); mockTapPoint.mockRejectedValue(new Error('touch should not be called for a stale iOS ref')); diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-press.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-press.test.ts index be2046aeda..01ef13c6f2 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-press.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-press.test.ts @@ -107,7 +107,7 @@ test('click on a macOS menubar wrapper ref promotes to the same-rect menu bar it createdAt: Date.now(), backend: 'macos-helper', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await handleInteractionCommands({ req: { @@ -164,7 +164,7 @@ test('press @ref promotes a non-hittable node to its hittable ancestor before ta createdAt: Date.now(), backend: 'xctest', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockTapPoint.mockResolvedValue({ pressed: true }); @@ -226,7 +226,7 @@ test('press @ref does not promote to a full-screen hittable ancestor', async () createdAt: Date.now(), backend: 'xctest', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockTapPoint.mockResolvedValue({ pressed: true }); @@ -285,7 +285,7 @@ test('click --button secondary on @ref dispatches a secondary press on macOS and createdAt: Date.now(), backend: 'xctest', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockTapPoint.mockResolvedValue({ button: 'secondary' }); @@ -321,7 +321,7 @@ test('click --button secondary on @ref dispatches a secondary press on macOS and test('#1654: a mutating find click acts on the node find resolved, not a re-resolved @ref', async () => { const sessionStore = makeSessionStore(); const sessionName = 'find-preresolved-click'; - sessionStore.set(sessionName, makeStaleRefSession(sessionName)); + sessionStore.publish(sessionName, makeStaleRefSession(sessionName)); const preresolved = makeFindPreresolvedTree(); const response = await runFindInternalClick(sessionStore, sessionName, { @@ -337,7 +337,7 @@ test('#1654: a mutating find click acts on the node find resolved, not a re-reso test('#1654 control: without the resolved-target payload the same click still resolves @ref from the session tree', async () => { const sessionStore = makeSessionStore(); const sessionName = 'find-preresolved-control'; - sessionStore.set(sessionName, makeStaleRefSession(sessionName)); + sessionStore.publish(sessionName, makeStaleRefSession(sessionName)); const response = await runFindInternalClick(sessionStore, sessionName, {}); @@ -355,7 +355,7 @@ test('#1654: the leaf performs no ref lookup at all — a session that could not const sessionName = 'find-preresolved-no-session-tree'; const session = makeSession(sessionName); session.snapshot = undefined; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const preresolved = makeFindPreresolvedTree(); const withoutPreresolution = await runFindInternalClick(sessionStore, sessionName, {}); @@ -374,7 +374,7 @@ test('#1654: the shared guards still run on the pre-resolved node', async () => // node is still refused, at the same ADR 0011 `runtime-ref` occlusion cell. const sessionStore = makeSessionStore(); const sessionName = 'find-preresolved-occluded'; - sessionStore.set(sessionName, makeStaleRefSession(sessionName)); + sessionStore.publish(sessionName, makeStaleRefSession(sessionName)); const preresolved = makeFindPreresolvedTree(); const blocked = { ...preresolved.node, @@ -396,7 +396,10 @@ test('#1654: the shared guards still run on the pre-resolved node', async () => test('a read right after a press captures the post-tap screen instead of reusing the pre-tap tree', async () => { const sessionStore = makeSessionStore(); const sessionName = 'read-after-press'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); const screen = (step: string) => ({ backend: 'xctest' as const, producer: 'apple-runner' as const, diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-response.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-response.test.ts index a471a83106..3f0a119d67 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-response.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-response.test.ts @@ -1,9 +1,17 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { + withDiagnosticsScope, + flushDiagnosticsToSessionFile, +} from '@agent-device/host-kit/diagnostics'; +import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts'; import { test, expect, vi, beforeEach } from 'vitest'; import { attachRefs } from '@agent-device/kernel/snapshot'; import { makeSessionStore } from '../../../../__tests__/test-utils/store-factory.ts'; import { handleInteractionCommands } from '../../index.ts'; import { buildInteractionResponseData, + buildTargetedTouchResponsePayloads, transformTouchResponseData, } from '../interaction-touch-response.ts'; import type { PressCommandResult } from '@agent-device/contracts/interaction'; @@ -100,6 +108,155 @@ function selectorResult(readiness?: { polls: number; waitedMs: number }): PressC const WAITED_SELECTOR_RESULT = selectorResult({ polls: 3, waitedMs: 400 }); +test.each([false, true])('selector-touch observation after retirement=%s', async (retired) => { + const sessionStore = makeSessionStore(); + const address = 'cwd:selector-touch-response:default'; + const session = makeSession('default'); + session.snapshot = { + nodes: attachRefs([{ index: 0, type: 'Button', label: 'Continue' }]), + createdAt: Date.now(), + backend: 'xctest', + }; + session.snapshotGeneration = 3; + const ref = sessionStore.publish(address, session); + const result: PressCommandResult = { + ...selectorResult(), + settle: { + settled: true, + waitedMs: 25, + captures: 2, + quietMs: 25, + timeoutMs: 2000, + diff: { + summary: { additions: 1, removals: 0, unchanged: 0 }, + lines: [{ kind: 'added', text: 'Continue', ref: 'e1' }], + }, + }, + }; + let successor; + if (retired) { + sessionStore.retire(ref); + successor = sessionStore.publish(address, makeSession('default')); + } + const payloads = await buildTargetedTouchResponsePayloads({ + params: { + req: { token: 't', command: 'press', positionals: ['@e1'], session: 'default' }, + sessionName: address, + sessionRef: ref, + sessionStore, + contextFromFlags, + captureSnapshotForSession: vi.fn(), + }, + result, + staleRefsWarning: undefined, + extra: {}, + }); + if (retired) { + expect(payloads.responseData.settle).toBeUndefined(); + expect(payloads.result.settle).toBeUndefined(); + expect(sessionStore.lookup(address)).toEqual(successor); + expect(successor?.session.snapshot).toBeUndefined(); + } else { + expect(payloads.responseData.settle).toMatchObject({ refsGeneration: 3, settled: true }); + } +}); + +test.each([ + [false, false], + [false, true], + [true, false], + [true, true], +])( + 'point-touch publication after a held frame probe, retired=%s, fails=%s', + async (retired, fails) => { + const sessionStore = makeSessionStore(); + const address = 'cwd:held-touch-response:default'; + const session = makeSession('default'); + session.snapshot = { + nodes: attachRefs([{ index: 0, type: 'Button', label: 'Continue' }]), + createdAt: Date.now(), + backend: 'xctest', + }; + session.snapshotGeneration = 3; + installTestScreenRecording(session); + const ref = sessionStore.publish(address, session); + let startProbe!: () => void; + let releaseProbe!: () => void; + const probing = new Promise((resolve) => { + startProbe = resolve; + }); + const released = new Promise((resolve) => { + releaseProbe = resolve; + }); + const result: PressCommandResult = { + kind: 'point', + point: { x: 10, y: 20 }, + settle: { + settled: true, + waitedMs: 25, + captures: 2, + quietMs: 25, + timeoutMs: 2000, + diff: { + summary: { additions: 1, removals: 0, unchanged: 0 }, + lines: [{ kind: 'added', text: 'Continue', ref: 'e1' }], + }, + }, + }; + const logPath = path.join(mkdtempForTestSync('held-point-'), 'request.log'); + const running = withDiagnosticsScope( + { command: 'press', session: address, logPath, debug: true }, + async () => { + const payloads = await buildTargetedTouchResponsePayloads({ + params: { + req: { + token: 't', + command: 'press', + positionals: ['10', '20'], + session: 'default', + flags: {}, + }, + sessionName: address, + sessionRef: ref, + sessionStore, + contextFromFlags, + captureSnapshotForSession: async () => { + startProbe(); + await released; + if (fails) throw new Error('frame capture failed'); + return sessionStore.requireCurrent(ref).snapshot!; + }, + }, + result, + staleRefsWarning: undefined, + extra: {}, + }); + flushDiagnosticsToSessionFile({ force: true }); + return payloads; + }, + ); + await probing; + let successor; + if (retired) { + sessionStore.retire(ref); + successor = sessionStore.publish(address, makeSession('default')); + } + releaseProbe(); + const payloads = await running; + const diagnostics = fs.existsSync(logPath) ? fs.readFileSync(logPath, 'utf8') : ''; + expect(diagnostics.includes('touch_reference_frame_resolve_failed')).toBe(!retired && fails); + expect(payloads.responseData.x).toBe(10); + if (retired) { + expect(payloads.responseData.settle).toBeUndefined(); + expect(payloads.result.settle).toBeUndefined(); + expect(sessionStore.lookup(address)).toEqual(successor); + expect(successor?.session.snapshot).toBeUndefined(); + } else { + expect(payloads.responseData.settle).toMatchObject({ refsGeneration: 3, settled: true }); + } + }, +); + test('the response builder reports the resolved wait and keeps the prior warning', () => { const { responseData, result } = buildInteractionResponseData({ source: { kind: 'runtime', result: WAITED_SELECTOR_RESULT }, @@ -139,7 +296,7 @@ test('press @ref --verify surfaces evidence through the interactionResultExtra a createdAt: Date.now(), backend: 'xctest', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); // Post-action capture reports an extra node, so changedFromBefore should // read true against the pre-action (stored) snapshot's single node. @@ -221,7 +378,7 @@ test('press @ref without --verify never includes an evidence field', async () => createdAt: Date.now(), backend: 'xctest', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockTapPoint.mockResolvedValue({ pressed: true }); const response = await handleInteractionCommands({ @@ -265,7 +422,7 @@ test('fill selector --verify surfaces evidence through the interactionResultExtr createdAt: Date.now(), backend: 'xctest', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockCaptureSnapshotForSession.mockResolvedValue({ nodes: [ @@ -326,7 +483,7 @@ test('fill @ref --verify surfaces evidence in the ref response branch', async () createdAt: Date.now(), backend: 'xctest', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockCaptureSnapshotForSession.mockResolvedValue({ nodes: [ @@ -395,7 +552,7 @@ test('fill @ref without --verify never includes an evidence field', async () => createdAt: Date.now(), backend: 'xctest', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await handleInteractionCommands({ req: { @@ -444,7 +601,7 @@ test('fill @ref preserves fallback coordinates for recording when platform resul startedAt: Date.now() - 1_000, showTouches: true, }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockFillPoint.mockResolvedValue({ filled: true }); const response = await handleInteractionCommands({ @@ -478,3 +635,35 @@ test('fill @ref preserves fallback coordinates for recording when platform resul expect(event?.x).toBe(60); expect(event?.y).toBe(40); }); + +test('an already retired coordinate touch skips its frame probe without a warning', async () => { + const sessionStore = makeSessionStore(); + const ref = sessionStore.publish('retired-point', makeSession('retired-point')); + sessionStore.retire(ref); + const capture = vi.fn(); + const logPath = path.join(mkdtempForTestSync('retired-point-'), 'request.log'); + await withDiagnosticsScope( + { command: 'click', session: ref.address, logPath, debug: true }, + async () => { + const payloads = await buildTargetedTouchResponsePayloads({ + params: { + req: { token: 't', command: 'click', positionals: ['1', '2'], session: ref.address }, + sessionName: ref.address, + sessionRef: ref, + sessionStore, + contextFromFlags, + captureSnapshotForSession: capture, + }, + result: { kind: 'point', point: { x: 1, y: 2 } }, + staleRefsWarning: undefined, + extra: {}, + }); + expect(payloads.responseData).toMatchObject({ x: 1, y: 2 }); + flushDiagnosticsToSessionFile({ force: true }); + }, + ); + expect(capture).not.toHaveBeenCalled(); + expect(fs.existsSync(logPath) ? fs.readFileSync(logPath, 'utf8') : '').not.toContain( + 'touch_reference_frame_resolve_failed', + ); +}); diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-runtime.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-runtime.test.ts index 6368761b3f..fda5a2b5da 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-runtime.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-runtime.test.ts @@ -82,7 +82,7 @@ test('press @ref taps the resolved point once and records the ref', async () => }, ]); session.snapshot = { nodes, createdAt: Date.now(), backend: 'xctest' }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await handleInteractionCommands({ req: { @@ -123,7 +123,7 @@ test('press @ref fails closed when the authorized ref has no usable bounds (ADR createdAt: Date.now(), backend: 'xctest', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockTapPoint.mockRejectedValue( new Error('dispatch must not run: no positional recapture on missing frame evidence'), @@ -180,7 +180,7 @@ test('press @ref fails closed when stored ref bounds are invalid (ADR 0014)', as createdAt: Date.now(), backend: 'android', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockTapPoint.mockRejectedValue( new Error('dispatch must not run: no positional recapture on unusable frame evidence'), @@ -236,7 +236,7 @@ test('press @ref fails fast when the target is off-screen', async () => { createdAt: Date.now(), backend: 'xctest', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await withRunner(() => handleInteractionCommands({ @@ -307,7 +307,7 @@ test('press @ref with a trailing label recovers within the authorized frame (no createdAt: Date.now(), backend: 'android', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockCaptureSnapshotForSession.mockRejectedValue( new Error('no positional recapture: recovery stays in-frame'), diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch.test.ts index 9fec76b04e..ac5e6465bf 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch.test.ts @@ -74,7 +74,7 @@ test('press coordinates dispatches press and records as press', async () => { const sessionStore = makeSessionStore(); const sessionName = 'default'; const storedSession = makeSession(sessionName); - sessionStore.set(sessionName, storedSession); + sessionStore.publish(sessionName, storedSession); const response = await handleInteractionCommands({ req: { @@ -116,7 +116,7 @@ test.each([ const sessionName = `single-bind-${command}`; const session = makeSession(sessionName); if (web) session.device = WEB_DESKTOP_DEVICE; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await handleInteractionCommands({ req: { token: 't', session: sessionName, command, positionals: [...positionals], flags: {} }, @@ -134,7 +134,7 @@ test.each([ test('click rejects macOS desktop surface interactions until helper routing exists', async () => { const sessionStore = makeSessionStore(); const sessionName = 'macos-desktop-click'; - sessionStore.set(sessionName, makeMacOsDesktopSession(sessionName)); + sessionStore.publish(sessionName, makeMacOsDesktopSession(sessionName)); const response = await handleInteractionCommands({ req: { @@ -160,7 +160,7 @@ test('click rejects macOS desktop surface interactions until helper routing exis test('fill rejects macOS menubar surface interactions until helper routing exists', async () => { const sessionStore = makeSessionStore(); const sessionName = 'macos-menubar-fill'; - sessionStore.set(sessionName, makeMacOsMenubarSession(sessionName)); + sessionStore.publish(sessionName, makeMacOsMenubarSession(sessionName)); const response = await handleInteractionCommands({ req: { @@ -201,7 +201,7 @@ test('longpress @ref resolves the target and dispatches coordinate longpress', a createdAt: Date.now(), backend: 'xctest', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await handleInteractionCommands({ req: { @@ -247,7 +247,7 @@ test('hover @ref on web dispatches through the provider hoverRef route, not coor createdAt: Date.now(), backend: 'web', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await handleInteractionCommands({ req: { token: 't', @@ -275,7 +275,7 @@ test('hover selector on web resolves the target and dispatches coordinate hover' const sessionName = 'hover-selector'; const session = makeSession(sessionName); session.device = WEB_DESKTOP_DEVICE; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockCaptureSnapshotForSession.mockResolvedValue({ nodes: attachRefs([ { @@ -315,7 +315,7 @@ test('hover selector on web resolves the target and dispatches coordinate hover' test('hover is refused by capability on touch platforms before any dispatch', async () => { const sessionStore = makeSessionStore(); const sessionName = 'hover-ios'; - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); const response = await handleInteractionCommands({ req: { diff --git a/src/daemon/interaction/internal/__tests__/interaction-type-android-readiness.test.ts b/src/daemon/interaction/internal/__tests__/interaction-type-android-readiness.test.ts index 9371210df3..35d40b2108 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-type-android-readiness.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-type-android-readiness.test.ts @@ -44,7 +44,7 @@ test('type continues and composes the recording readiness warning', async () => startedAt: 0, }); const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await handleInteractionCommands({ req: { diff --git a/src/daemon/interaction/internal/find-target-capture.ts b/src/daemon/interaction/internal/find-target-capture.ts index e0b906f539..3109598692 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,8 +19,8 @@ export type FindTargetTree = CaptureProvenance & */ export function createFindTargetCapture( params: Readonly<{ + ref: SessionRef; device: SessionState['device']; - session: SessionState; req: DaemonRequest; logPath: string; locator: FindLocator; @@ -35,10 +35,10 @@ export function createFindTargetCapture( captureProof: RequestCaptureProof; }>, ): () => Promise { - const { device, session, req, logPath, locator, query, sessionStore, sessionName } = params; + const { device, req, logPath, locator, query, sessionStore, sessionName } = params; const captureRuntime = createSelectorCaptureRuntime({ + ref: params.ref, device, - session, sessionStore, sessionName, req, diff --git a/src/daemon/interaction/internal/find.ts b/src/daemon/interaction/internal/find.ts index 4b25bdfc64..1057e97804 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,7 +44,7 @@ type FindContext = { logPath: string; sessionStore: SessionStore; invoke: DaemonInvokeFn; - session: SessionState; + sessionRef: SessionRef; device: SessionState['device']; command: string; locator: FindLocator; @@ -75,6 +75,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 +107,8 @@ export async function handleFindCommands(params: FindRouteInput): Promise { - const { req, sessionName, sessionStore, session, invoke, command, locator, query, publicFlags } = - ctx; + const { + req, + sessionName, + sessionStore, + sessionRef, + invoke, + command, + locator, + query, + publicFlags, + } = ctx; const response = await invoke({ token: req.token, session: sessionName, @@ -297,7 +307,7 @@ async function handleFindClick(ctx: FindContext, match: ResolvedMatch): Promise< Object.assign(matchData, successText(clickMessage)); recordSessionAction( sessionStore, - session, + sessionRef, req, command, { ref: match.ref, action: 'click', locator, query }, @@ -311,7 +321,7 @@ async function handleFindFill( match: ResolvedMatch, value: string | undefined, ): Promise { - const { req, sessionName, sessionStore, session, invoke, command, publicFlags } = ctx; + const { req, sessionName, sessionStore, sessionRef, invoke, command, publicFlags } = ctx; // `''` is the clear request (#2063); only a MISSING value is an error. if (value === undefined) { return errorResponse('INVALID_ARGS', 'find fill requires text (use "" to clear the field)'); @@ -327,7 +337,7 @@ async function handleFindFill( if (!response.ok) return response; recordSessionAction( sessionStore, - session, + sessionRef, req, command, { ref: match.ref, action: 'fill' }, @@ -348,12 +358,13 @@ async function handleFindType( match: ResolvedMatch, value: string | undefined, ): Promise { - const { req, logPath, session } = ctx; + const { req, logPath } = ctx; if (!value) { return errorResponse('INVALID_ARGS', 'find type requires text'); } const focusResponse = await dispatchFocusForFindMatch(ctx, match); if (!focusResponse.ok) return focusResponse; + const session = ctx.sessionStore.requireCurrent(ctx.sessionRef); // The focus above already crossed the seam; expiry is idempotent, but keep it // explicit at the type dispatch so it does not rely on the focus-first order. expireRefFrame(session); @@ -376,7 +387,7 @@ async function dispatchFocusForFindMatch( ctx: FindContext, match: ResolvedMatch, ): Promise { - const { req, logPath, session } = ctx; + const { req, logPath } = ctx; const coveredResponse = rejectCoveredFindMatch(match, 'be focused'); if (coveredResponse) return coveredResponse; const coords = match.resolvedNode.rect ? centerOfRect(match.resolvedNode.rect) : null; @@ -386,6 +397,7 @@ async function dispatchFocusForFindMatch( // ADR 0014 side-effect seam: mutating find focus/type dispatch the device // command directly (they do not re-enter the interaction leaf), so expire the // frame here before the device op. Pre-seam guards above preserve the frame. + const session = ctx.sessionStore.requireCurrent(ctx.sessionRef); expireRefFrame(session); // R40/R35: the operation came from the handler's ONE action-selected bind; the shared // executor is the single lexical owner of the `focusPoint` call. @@ -416,10 +428,10 @@ function rejectCoveredFindMatch(match: ResolvedMatch, interaction: string): Daem } function recordFindAction(ctx: FindContext, match: ResolvedMatch, action: string): void { - const { req, sessionStore, session, command, publicFlags } = ctx; + const { req, sessionStore, sessionRef, command, publicFlags } = ctx; recordSessionAction( sessionStore, - session, + sessionRef, req, command, { ref: match.ref, action }, diff --git a/src/daemon/interaction/internal/interaction-gesture.ts b/src/daemon/interaction/internal/interaction-gesture.ts index 7e4192bc89..59eb620d8a 100644 --- a/src/daemon/interaction/internal/interaction-gesture.ts +++ b/src/daemon/interaction/internal/interaction-gesture.ts @@ -1,3 +1,4 @@ +import { bindInteractionSession } from './interaction-session.ts'; import { readOptionalInteger } from '@agent-device/contracts/command'; import { type GesturePayload, readGesturePayload } from '@agent-device/contracts/gesture-input'; import { @@ -71,6 +72,7 @@ function isRefusal( export async function dispatchGestureViaRuntime( params: GestureHandlerParams, ): 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 @@ -236,7 +239,7 @@ async function dispatchGestureInteraction( responseData.warning = `${existingWarning}${readiness.warning}`; } return finalizeTouchInteraction({ - session, + ref: params.sessionRef!, sessionStore: params.sessionStore, command, actionCommand: command, diff --git a/src/daemon/interaction/internal/interaction-ios-tap-outcome.ts b/src/daemon/interaction/internal/interaction-ios-tap-outcome.ts index a9fc2df1b1..5fd0566d49 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, SessionState } 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; @@ -54,8 +54,9 @@ export type IosTapCorroboration = { export async function corroborateIosTapFailure( params: IosTapCorroborationParams, ): Promise { - if (!canCorroborateIosTapFailure(params)) return undefined; - const baseline = readCorroborationBaseline(params.session.snapshot); + const session = params.sessionStore.resolveCurrent(params.ref); + if (!session || !canCorroborateIosTapFailure(params, session)) return undefined; + const baseline = readCorroborationBaseline(session.snapshot); if (!baseline) return undefined; const after = await captureCorroborationSnapshot( @@ -69,11 +70,14 @@ export async function corroborateIosTapFailure( return compareCorroborationEvidence(baseline.snapshot, after, params.command); } -function canCorroborateIosTapFailure(params: IosTapCorroborationParams): boolean { +function canCorroborateIosTapFailure( + params: IosTapCorroborationParams, + session: SessionState, +): boolean { return ( isTapCommand(params.command) && asAppError(params.error).code === XCTEST_RECORDED_FAILURE && - isLocalIosRunnerSession(params.session, { skipPendingPostGestureStabilization: false }) + isLocalIosRunnerSession(session, { skipPendingPostGestureStabilization: false }) ); } @@ -121,7 +125,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..74b9ad5151 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 { @@ -28,7 +29,7 @@ import { isSessionRecording } from '../../session-script-publication-capability. import { recordTouchVisualizationEvent } from '../../recording-gestures.ts'; import type { SessionStore } from '../../session-store.ts'; import type { DaemonResponse } from '../../daemon-request.ts'; -import type { SessionState } from '../../session-state.ts'; +import type { SessionRef } from '../../session-state.ts'; import type { BoundTouchExecutor } from '../../touch-runtime.ts'; import type { BoundGestureExecutor } from '../../gesture-runtime.ts'; import { NO_ACTIVE_SESSION_MESSAGE } from '@agent-device/kernel/contracts'; @@ -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, }) @@ -97,16 +99,17 @@ type FinalizeTouchInteractionInput = Omit< Parameters[0], 'operations' > & { - session: SessionState; + ref: SessionRef; sessionStore: SessionStore; }; export function finalizeTouchInteraction(params: FinalizeTouchInteractionInput): DaemonResponse { - const { session, sessionStore, ...finalization } = params; + const { ref, sessionStore, ...finalization } = params; + const session = sessionStore.requireCurrent(ref); return finalizeInteraction({ ...finalization, operations: { - recordAction: sessionStore.recordAction.bind(sessionStore, session), + recordAction: sessionStore.recordAction.bind(sessionStore, ref), markDeferredOutcome: (mark) => markDeferredInteractionOutcome({ session, ...mark }), isSessionRecording: isSessionRecording.bind(null, session), recordGestureVisualization: recordTouchVisualizationEvent.bind(null, session), 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..96564df057 100644 --- a/src/daemon/interaction/internal/interaction-touch-direct-ios.ts +++ b/src/daemon/interaction/internal/interaction-touch-direct-ios.ts @@ -72,7 +72,7 @@ export async function dispatchDirectIosSelectorTap( }, }); return finalizeTouchInteraction({ - session, + ref: handlerParams.sessionRef!, sessionStore: handlerParams.sessionStore, command: handlerParams.req.command, positionals: handlerParams.req.positionals ?? [], @@ -86,7 +86,6 @@ export async function dispatchDirectIosSelectorTap( const corroboratedResponse = await buildDirectIosCorroboratedResponse({ error, handlerParams, - session, extra, positionals: [], actionStartedAt, @@ -118,18 +117,17 @@ async function buildDirectIosCorroboratedResponse(params: { handlerParams: InteractionRouteInput & { captureSnapshotForSession: CaptureSnapshotForSession; }; - session: SessionState; extra: Record; positionals: string[]; actionStartedAt: number; }): Promise { - const { error, handlerParams, session, extra, positionals, actionStartedAt } = params; + const { error, handlerParams, extra, positionals, actionStartedAt } = params; const corroboration = await corroborateIosTapFailure({ error, 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, @@ -143,7 +141,7 @@ async function buildDirectIosCorroboratedResponse(params: { extra, }); return finalizeTouchInteraction({ - session, + ref: handlerParams.sessionRef!, sessionStore: handlerParams.sessionStore, command: handlerParams.req.command, positionals: handlerParams.req.positionals ?? positionals, 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..33779fdba0 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; @@ -129,7 +131,6 @@ function buildTargetedRuntimeOptions( const resultDurationMs = readLongPressResultDuration(result); return await buildTargetedTouchResponsePayloads({ params, - session, result, staleRefsWarning, publicData: transformTouchResponseData({ diff --git a/src/daemon/interaction/internal/interaction-touch-reference-frame.ts b/src/daemon/interaction/internal/interaction-touch-reference-frame.ts index 0020fdb596..62955be1f9 100644 --- a/src/daemon/interaction/internal/interaction-touch-reference-frame.ts +++ b/src/daemon/interaction/internal/interaction-touch-reference-frame.ts @@ -4,20 +4,22 @@ 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.resolveCurrent(ref); + if (!session) return undefined; const recording = session.screenRecording?.handle; if (!recording) { return undefined; @@ -48,7 +50,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 +59,7 @@ async function resolveDirectTouchReferenceFrame(params: { } export async function resolveDirectTouchReferenceFrameSafely(params: { - session: SessionState; + ref: SessionRef; flags: CommandFlags | undefined; sessionStore: SessionStore; contextFromFlags: BoundContextFromFlags; @@ -67,11 +69,13 @@ export async function resolveDirectTouchReferenceFrameSafely(params: { try { return await resolveDirectTouchReferenceFrame(params); } catch (error) { + const current = params.sessionStore.resolveCurrent(params.ref); + if (!current) return undefined; emitDiagnostic({ level: 'warn', phase: 'touch_reference_frame_resolve_failed', data: { - platform: params.session.device.platform, + platform: current.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..74fed83a21 100644 --- a/src/daemon/interaction/internal/interaction-touch-response.ts +++ b/src/daemon/interaction/internal/interaction-touch-response.ts @@ -17,7 +17,6 @@ import { } from '../../../core/interaction-response-data-transform.ts'; import { issueSettleRefs } from '../../session-snapshot.ts'; import type { RecordedTargetCapture } from '@agent-device/selectors/target-evidence'; -import type { SessionState } from '../../session-state.ts'; import type { CaptureSnapshotForSession, InteractionRouteInput } from './types.ts'; import { resolveDirectTouchReferenceFrameSafely } from './interaction-touch-reference-frame.ts'; import { readSnapshotNodesReferenceFrame } from '@agent-device/capture-kit/touch-reference-frame'; @@ -288,30 +287,40 @@ export async function buildTargetedTouchResponsePayloads(params: { params: InteractionRouteInput & { captureSnapshotForSession: CaptureSnapshotForSession; }; - session: SessionState; result: TargetedTouchResult; staleRefsWarning: string | undefined; publicData?: Record; extra: Record; }): Promise { - const { params: handlerParams, session, result, publicData, extra } = params; - const referenceFrame = + const { params: handlerParams, result, publicData, extra } = params; + const probedFrame = 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 ?? []); + : undefined; + const current = handlerParams.sessionStore.resolveCurrent(handlerParams.sessionRef!); + const referenceFrame = current + ? result.kind === 'point' + ? probedFrame + : readSnapshotNodesReferenceFrame(current.snapshot?.nodes ?? []) + : undefined; + const currentResult = current ? result : { ...result, settle: undefined }; return buildInteractionResponseData({ - source: { kind: 'runtime', result, publicData }, + source: { kind: 'runtime', result: currentResult, publicData }, referenceFrame, extra, staleRefsWarning: params.staleRefsWarning, - settleRefsGeneration: issueSettleRefs(session, result.settle), + settleRefsGeneration: issueSettleRefs( + handlerParams.sessionRef, + handlerParams.sessionStore, + currentResult.settle, + ), }); } diff --git a/src/daemon/interaction/internal/interaction-touch-runtime.ts b/src/daemon/interaction/internal/interaction-touch-runtime.ts index 6bbc473006..27ed55f7b1 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, @@ -106,7 +108,7 @@ export async function dispatchRuntimeInteraction< responseData.warning = warning; } return finalizeTouchInteraction({ - session, + ref: params.sessionRef, sessionStore: params.sessionStore, command: params.req.command, positionals: params.req.positionals ?? [], @@ -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, @@ -174,7 +176,7 @@ async function buildRuntimeIosCorroboratedResponse(params: { extra: params.extra, }); return finalizeTouchInteraction({ - session: params.session, + ref: params.handlerParams.sessionRef!, sessionStore: params.handlerParams.sessionStore, command: params.handlerParams.req.command, positionals: params.handlerParams.req.positionals ?? [], diff --git a/src/daemon/interaction/internal/interaction.ts b/src/daemon/interaction/internal/interaction.ts index 1cc82a0a63..f39fa054f5 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 }) @@ -164,7 +168,7 @@ async function runTypeTextViaRuntime( const responseData: Record = { ...result }; appendTypeReadinessWarnings(responseData, recordingRecoveryWarning, readiness); return finalizeTouchInteraction({ - session, + ref: params.sessionRef!, sessionStore, command: req.command, positionals: req.positionals ?? [], 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/internal-observation.ts b/src/daemon/internal-observation.ts index c616d7a61b..ad1e4bff5b 100644 --- a/src/daemon/internal-observation.ts +++ b/src/daemon/internal-observation.ts @@ -9,12 +9,11 @@ import type { import { readSessionRuntimeRevision, refFrame } from './ref-frame.ts'; import type { RefFrame } from './ref-frame-slot.ts'; import { markSessionPartialRefsIssued, setSessionSnapshot } from './session-snapshot.ts'; -import type { SessionState } from './session-state.ts'; +import type { SessionRef, SessionState } from './session-state.ts'; +import type { SessionStore } from './session-store.ts'; type InternalObservationLineage = Readonly<{ - sessionName: string; - session: SessionState; - sessionCreatedAt: number; + ref: SessionRef; snapshot: SnapshotState; snapshotGeneration: number; runtimeRevision: number; @@ -24,16 +23,11 @@ type InternalObservationLineage = Readonly<{ const evidenceLineage = new WeakMap(); type BoundInternalObservationSession = Readonly<{ - sessionStore: InternalObservationSessionStore; - sessionName: string; + sessionStore: SessionStore; + ref: SessionRef | undefined; signal?: AbortSignal; }>; -type InternalObservationSessionStore = Readonly<{ - get: () => SessionState | undefined; - update: (mutate: (session: SessionState) => void) => boolean; -}>; - /** * Bind observation authority to one already admitted, locked session. Callers * cannot address another session through the resulting capability, and engines @@ -58,21 +52,15 @@ export function bindInternalObservationAuthority( * without activating, replacing, or expiring client ref authority. */ function storeInternalObservation( - params: Pick, + params: Pick, snapshot: SnapshotState, ): ReplayObservationCapture { - const { sessionStore, sessionName } = params; - let storedSession: SessionState | undefined; - if ( - !sessionStore.update((session) => { - setSessionSnapshot(session, snapshot); - storedSession = session; - }) || - !storedSession - ) { + const { sessionStore, ref } = params; + if (!ref) { throw new Error('Internal observation session is no longer available.'); } - const session = storedSession; + const session = sessionStore.requireCurrent(ref); + setSessionSnapshot(session, snapshot); const snapshotGeneration = session.snapshotGeneration; if (snapshotGeneration === undefined) { throw new Error('Internal observation did not establish a snapshot generation.'); @@ -80,9 +68,7 @@ function storeInternalObservation( const evidence = {} as ReplayObservationEvidence; evidenceLineage.set(evidence, { - sessionName, - session, - sessionCreatedAt: session.createdAt, + ref, snapshot, snapshotGeneration, runtimeRevision: readSessionRuntimeRevision(session), @@ -100,8 +86,8 @@ function storeInternalObservation( * before the response returns to the client. */ function finalizeClientRefPublication(params: { - sessionStore: InternalObservationSessionStore; - sessionName: string; + sessionStore: SessionStore; + ref: SessionRef | undefined; evidence: ReplayObservationEvidence; projection: ReplayRefPublicationProjection; signal?: AbortSignal; @@ -115,7 +101,8 @@ function finalizeClientRefPublication(params: { if (refs.size === 0) return { published: false, reason: 'empty' }; if (params.signal?.aborted === true) return { published: false, reason: 'cancelled' }; - if (!lineage || !isCurrentLineage(params, lineage)) { + const session = lineage && resolveCurrentLineage(params, lineage); + if (!lineage || !session) { return { published: false, reason: 'stale-capture' }; } if ( @@ -125,7 +112,7 @@ function finalizeClientRefPublication(params: { return { published: false, reason: 'invalid-projection' }; } - markSessionPartialRefsIssued(lineage.session, refs); + markSessionPartialRefsIssued(session, refs); return { published: true, refsGeneration: lineage.snapshotGeneration, @@ -133,20 +120,19 @@ function finalizeClientRefPublication(params: { }; } -function isCurrentLineage( - params: Pick, +function resolveCurrentLineage( + params: Pick, lineage: InternalObservationLineage, -): boolean { - const current = params.sessionStore.get(); - return ( - params.sessionName === lineage.sessionName && - current === lineage.session && - current.createdAt === lineage.sessionCreatedAt && +): SessionState | undefined { + if (!params.ref || params.ref.lifetime !== lineage.ref.lifetime) return undefined; + const current = params.sessionStore.resolveCurrent(params.ref); + return current && current.snapshot === lineage.snapshot && current.snapshotGeneration === lineage.snapshotGeneration && readSessionRuntimeRevision(current) === lineage.runtimeRevision && refFrame(current) === lineage.refFrame - ); + ? current + : undefined; } function normalizeRefBodies(refs: readonly string[]): Set { diff --git a/src/daemon/lease-lifecycle.ts b/src/daemon/lease-lifecycle.ts index cc70befb02..04866c889b 100644 --- a/src/daemon/lease-lifecycle.ts +++ b/src/daemon/lease-lifecycle.ts @@ -14,12 +14,12 @@ import { } from './request-admission.ts'; import type { SessionStore } from './session-store.ts'; import type { DaemonRequest } from './daemon-request.ts'; -import type { SessionState } from './session-state.ts'; +import type { SessionRef, SessionState } from './session-state.ts'; import { providerSessionIdFromData } from './provider-session-ownership.ts'; export type ExpiredProviderLeaseRecovery = (lease: DeviceLease) => Promise; -export type SessionTeardown = (session: SessionState, sessionName: string) => Promise; +export type SessionTeardown = (ref: SessionRef) => Promise; export async function releaseExpiredProviderLease( recoverExpiredLease: ExpiredProviderLeaseRecovery | undefined, @@ -62,9 +62,10 @@ export async function cleanupExpiredLeasedSession(params: { leaseRegistry: LeaseRegistry; teardownSession: SessionTeardown; }): Promise { - const session = params.sessionStore.get(params.sessionName); + const ref = params.sessionStore.lookup(params.sessionName); + const session = ref?.session; const lease = session?.lease; - if (!session || !lease) return false; + if (!ref || !session || !lease) return false; const expiredLease = params.leaseRegistry.consumeExpiredLease(lease.leaseId); if (!expiredLease) return false; emitDiagnostic({ @@ -77,7 +78,7 @@ export async function cleanupExpiredLeasedSession(params: { deviceKey: lease.deviceKey, }, }); - await params.teardownSession(session, session.name).catch((error) => { + await params.teardownSession(ref).catch((error) => { emitDiagnostic({ level: 'debug', phase: 'leased_session_expiry_cleanup_failed', @@ -100,7 +101,7 @@ export async function cleanupExpiredLeasedSession(params: { }, }); }); - params.sessionStore.delete(session.name); + params.sessionStore.retire(ref); return true; } @@ -112,7 +113,8 @@ export function admitRequestLeaseForLockedScope(params: { providerAppCatalog?: ProviderAppCatalog; }): DaemonRequest { const { sessionName, sessionStore, leaseRegistry } = params; - const existingSession = sessionStore.get(sessionName); + const ref = sessionStore.lookup(sessionName); + const existingSession = ref?.session; const activeLease = assertRequestLeaseAdmission(params.req, leaseRegistry, existingSession, { providerAppCatalog: params.providerAppCatalog, }); @@ -125,15 +127,14 @@ export function admitRequestLeaseForLockedScope(params: { admittedLease: activeLease, }, }; - if (existingSession?.lease) { - sessionStore.set(sessionName, { - ...existingSession, + if (ref && existingSession?.lease) { + sessionStore.update(ref, (current) => ({ lease: { - ...existingSession.lease, + ...current.lease!, leaseBackend: activeLease.backend, expiresAt: activeLease.expiresAt, }, - }); + })); } return nextReq; } 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 2fcf80bc7c..56423054cb 100644 --- a/src/daemon/request-execution-scope.ts +++ b/src/daemon/request-execution-scope.ts @@ -45,7 +45,7 @@ import type { LeaseRegistry } from './lease-registry.ts'; import { type SessionStore } from './session-store.ts'; import { resolveSessionRequestLog, resolveSessionRunnerLogPath } from './session-artifact-paths.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 { teardownSessionResources } from './session-teardown.ts'; import { finalizeBoundSessionApplicationLifecycle } from './application-lifecycle-recovery.ts'; import { runtimeHintValues } from './session-runtime.ts'; @@ -273,10 +273,9 @@ export async function createRequestExecutionScope(params: { sessionName, sessionStore, leaseRegistry, - teardownSession: async (session, expiredSessionName) => + teardownSession: async (ref) => await teardownExpiredSession({ - session, - sessionName: expiredSessionName, + ref, sessionStore, inspectFacts: scope.inspectFacts, bindDevice: scope.bindDevice, @@ -410,20 +409,21 @@ function createRequestDeviceAccess(params: { } async function teardownExpiredSession(params: { - session: SessionState; - sessionName: string; + ref: SessionRef; sessionStore: SessionStore; inspectFacts: InspectDeviceRuntimeFacts; bindDevice: BindDeviceRuntime; platformCleanup: PlatformResourceCleanup; }): Promise { - const { session, sessionName, sessionStore, inspectFacts, bindDevice, platformCleanup } = params; + const { ref, sessionStore, inspectFacts, bindDevice, platformCleanup } = params; + const session = sessionStore.resolveCurrent(ref) ?? ref.session; + const sessionName = ref.address; + const runtimeHints = runtimeHintValues(sessionStore.getRuntimeHints(ref.address)); let primaryError: unknown; try { await teardownSessionResources({ appLog: 'run', - session, - sessionName, + ref, sessionStore, platformCleanup, }); @@ -436,7 +436,7 @@ async function teardownExpiredSession(params: { bindDevice, session, stateDir: sessionStore.resolveDaemonStateDir(), - runtimeHints: runtimeHintValues(sessionStore.getRuntimeHints(sessionName)), + runtimeHints, }); } catch (cleanupError) { if (primaryError !== undefined) { @@ -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..37bd6db269 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, @@ -61,7 +61,7 @@ export type ResolvedGenericExecution = export async function dispatchGenericCommand(params: { req: DaemonRequest; - session: SessionState; + ref: SessionRef; sessionName: string; logPath: string; sessionStore: SessionStore; @@ -74,7 +74,8 @@ export async function dispatchGenericCommand(params: { recordedRequest?: RecordedGenericRequest; androidObservation?: AndroidObservationAdapter; }): Promise { - const { req, session, logPath, sessionStore, contextFromFlags } = params; + const { req, ref: sessionRef, logPath, sessionStore, contextFromFlags } = params; + const session = sessionStore.requireCurrent(sessionRef); 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, @@ -110,9 +112,10 @@ export async function dispatchGenericCommand(params: { }; const actionStartedAt = Date.now(); + const current = sessionStore.requireCurrent(sessionRef); const dispatchContext = { - ...contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath), - surface: session.surface, + ...contextFromFlags(req.flags, current.appBundleId, current.trace?.outPath), + surface: current.surface, }; // ADR 0014 side-effect seam for generic-route leaves (back/home/rotate/scroll/ // tv-remote/app-switcher/viewport/focus). Effect classification @@ -120,10 +123,10 @@ export async function dispatchGenericCommand(params: { // before dispatching so a later ref cannot reuse it. Read-only generic leaves // (screenshot) are classified `preserve` and leave the frame untouched. if (resolveRefFrameEffect(req) === 'may-invalidate') { - expireRefFrame(session); + expireRefFrame(current); } const data = await params.executePlatformCommand({ - session, + session: current, sessionName: params.sessionName, logPath, command: platformCommand, @@ -134,7 +137,7 @@ export async function dispatchGenericCommand(params: { }); return await finalizeGenericCommand({ req, - session, + ref: sessionRef, sessionStore, command: platformCommand, resolvedPositionals, @@ -157,7 +160,7 @@ export async function dispatchGenericCommand(params: { */ async function finalizeGenericCommand(params: { req: DaemonRequest; - session: SessionState; + ref: SessionRef; sessionStore: SessionStore; command: string; resolvedPositionals: string[]; @@ -168,9 +171,9 @@ async function finalizeGenericCommand(params: { observeSettle?: () => Promise; androidObservation?: AndroidObservationAdapter; }): Promise { - const { req, session, sessionStore, command } = params; + const { req, ref, sessionStore, command } = params; const postflightReadiness = await ensureNoAndroidBlockingDialogReady( - session, + sessionStore.requireCurrent(ref), command, params.androidObservation, 'after-command', @@ -179,7 +182,7 @@ async function finalizeGenericCommand(params: { let data = withReadinessWarnings(params.data, params.readinessWarnings); recordVisualizationAndAction({ - session, + ref, sessionStore, command, resolvedPositionals: params.resolvedPositionals, @@ -192,7 +195,7 @@ async function finalizeGenericCommand(params: { }); markDeferredInteractionOutcome({ - session, + session: sessionStore.requireCurrent(ref), command, positionals: params.resolvedPositionals, flags: req.flags, @@ -234,6 +237,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; @@ -329,7 +333,7 @@ async function ensureGenericCommandReady( } function recordVisualizationAndAction(params: { - session: SessionState; + ref: SessionRef; sessionStore: SessionStore; command: string; resolvedPositionals: string[]; @@ -341,7 +345,7 @@ function recordVisualizationAndAction(params: { clientArtifactPaths: Record | undefined; }): void { const { - session, + ref, sessionStore, command, resolvedPositionals, @@ -352,6 +356,7 @@ function recordVisualizationAndAction(params: { flags, clientArtifactPaths, } = params; + const session = sessionStore.requireCurrent(ref); const visualizationData = augmentScrollVisualizationResult( session, command, @@ -367,7 +372,7 @@ function recordVisualizationAndAction(params: { actionStartedAt, actionFinishedAt, ); - sessionStore.recordAction(session, { + sessionStore.recordAction(ref, { command, positionals: recorded.positionals, flags: recorded.flags, 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..52da4cdef3 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -493,10 +493,11 @@ async function dispatchGenericForLockedScope(params: { androidObservation: AndroidObservationAdapter; }): Promise { const { lockedScope, logPath, sessionStore, androidObservation } = params; - const session = sessionStore.get(lockedScope.sessionName); - if (!session) { + const ref = sessionStore.lookup(lockedScope.sessionName); + if (!ref) { return noActiveSessionError(); } + const session = sessionStore.requireCurrent(ref); const runtimeExecution = await resolveGenericRuntimeExecution({ req: lockedScope.req, @@ -516,7 +517,7 @@ async function dispatchGenericForLockedScope(params: { const { dispatchGenericCommand } = await loadGenericRequestHandlerModule(); const dispatchResponse = await dispatchGenericCommand({ req: lockedScope.req, - session, + ref, sessionName: lockedScope.sessionName, logPath, sessionStore, 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/screen-recording-session-binding.ts b/src/daemon/screen-recording-session-binding.ts new file mode 100644 index 0000000000..4c4505b3ee --- /dev/null +++ b/src/daemon/screen-recording-session-binding.ts @@ -0,0 +1,32 @@ +import { bindSessionScreenRecording } from './session-capture-binding.ts'; +import type { SessionRef } from './session-state.ts'; +import type { SessionStore } from './session-store.ts'; + +export function bindRecordOnlyScreenRecording( + sessionStore: SessionStore, + address: string, + draft: SessionRef['session'], +) { + let published: SessionRef | undefined; + const binding: ReturnType = Object.freeze({ + address, + sessionDir: sessionStore.resolveSessionDir(address), + read: () => + published ? bindSessionScreenRecording(sessionStore, published).read() : undefined, + assertAdoptable: () => sessionStore.assertPublishable(address), + canPersist: () => !published && sessionStore.lookup(address) === undefined, + adopt: (screenRecording) => { + sessionStore.assertPublishable(address); + published = sessionStore.publish(address, { ...draft, screenRecording }); + }, + clear: (expected) => + published ? bindSessionScreenRecording(sessionStore, published).clear(expected) : 'retired', + }); + return Object.freeze({ + binding, + requireRef: (): SessionRef => { + if (!published) throw new TypeError('Screen recording did not publish its session'); + return published; + }, + }); +} diff --git a/src/daemon/screenshot-runtime.ts b/src/daemon/screenshot-runtime.ts index f0e4c76477..b07e829607 100644 --- a/src/daemon/screenshot-runtime.ts +++ b/src/daemon/screenshot-runtime.ts @@ -1,3 +1,4 @@ +import { expandSessionPath } from '@agent-device/host-kit/session-paths'; import type { CommandFlags } from '@agent-device/contracts/command'; import { retiredScreenshotMaxSizeFlagError, @@ -28,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'; @@ -38,7 +39,6 @@ import { type ScreenshotRuntimeBindings, } from './screenshot-runtime-binding.ts'; import { setSessionSnapshot } from './session-snapshot.ts'; -import { SessionStore } from './session-store.ts'; import type { DaemonRequest } from './daemon-request.ts'; import type { SessionState } from './session-state.ts'; @@ -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(), }); @@ -321,7 +316,7 @@ function readScreenshotRequest( const positionals = req.positionals ?? []; const flags = req.flags ?? {}; const expand = (value: string | undefined) => - value === undefined ? undefined : SessionStore.expandHome(value, req.meta?.cwd); + value === undefined ? undefined : expandSessionPath(value, req.meta?.cwd); const positionalPath = expand(positionals[0]); const outFlag = expand(flags.out); return { diff --git a/src/daemon/selector-capture-runtime.ts b/src/daemon/selector-capture-runtime.ts index 18a2664970..6bd2a92089 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,8 +24,8 @@ 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; sessionName: string; req: DaemonRequest; @@ -81,7 +81,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 +92,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 +119,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; @@ -195,6 +196,7 @@ async function runCapture( snapshotScope: string | undefined, interactiveOnly = request.flags?.snapshotInteractiveOnly, ): Promise { + const session = params.ref ? params.sessionStore.requireCurrent(params.ref) : undefined; const flags = { ...request.flags, snapshotInteractiveOnly: interactiveOnly, @@ -202,7 +204,7 @@ async function runCapture( const boundCapture = params.capture; const capture = await captureSnapshot({ device: params.device, - session: params.session, + session, flags, outPath: request.outPath ?? params.req.flags?.out, logPath: params.logPath ?? '', @@ -215,7 +217,7 @@ async function runCapture( flags, logPath: params.logPath ?? '', meta: params.req.meta, - session: params.session, + session, snapshotScope, includeRects: request.includeRects, // The POLL's remaining budget, not the request's. A binding's signal is fixed at @@ -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-recording.ts b/src/daemon/selector-recording.ts index b03679e242..e7e141313a 100644 --- a/src/daemon/selector-recording.ts +++ b/src/daemon/selector-recording.ts @@ -3,6 +3,7 @@ import type { SnapshotNode } from '@agent-device/kernel/snapshot'; import type { FindReadResult } from '@agent-device/contracts/interaction'; import { stripAndroidSystemChromeProvenanceFromNode } from '@agent-device/contracts/android-system-chrome'; import { SessionStore } from './session-store.ts'; +import type { SessionRef } from './session-state.ts'; import { isInteractiveObservation } from './session-action-recorder.ts'; import { isSessionRecording } from './session-script-publication-capability.ts'; import { @@ -127,7 +128,7 @@ export function stripSelectorChain>(result: T) export function recordIfSession( sessionStore: SessionStore, - sessionName: string, + ref: SessionRef | undefined, req: DaemonRequest, result: Record, /** ADR 0012 decision 3: record-time input for the `target-v1` annotation. */ @@ -135,13 +136,13 @@ export function recordIfSession( /** #1349: `landmark` for wait's existence-semantics evidence; defaults to `action`. */ evidenceMode?: TargetEvidenceMode, ): void { - const session = sessionStore.get(sessionName); - if (!session) return; + if (!ref) return; + const session = sessionStore.requireCurrent(ref); const targetEvidence = isSessionRecording(session) && recordedTarget ? computeTargetEvidence(recordedTarget, { mode: evidenceMode }) : undefined; - sessionStore.recordAction(session, { + sessionStore.recordAction(ref, { command: req.command, positionals: req.positionals ?? [], flags: req.flags ?? {}, diff --git a/src/daemon/selector-runtime-backend.test.ts b/src/daemon/selector-runtime-backend.test.ts index e17551b3f4..a1d9a82584 100644 --- a/src/daemon/selector-runtime-backend.test.ts +++ b/src/daemon/selector-runtime-backend.test.ts @@ -30,7 +30,7 @@ test('wait text passes its poll deadline signal to the Apple runner fast path', createdAt: Date.now(), actions: [], }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); let observedSignal: AbortSignal | undefined; // R35: every selector runtime is bound, so the poll deadline must reach the platform through // the bound capture's per-capture signal — the seam the runner used to be reached through. @@ -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, @@ -100,7 +101,7 @@ test('daemon wait stable pins private-ax on emitted snapshot runner requests', a actions: [], snapshot, }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const capture = vi.fn(async (_input: CaptureSnapshotInput): Promise => ({ backend: 'xctest', producer: 'apple-runner', @@ -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..24ad3eeb8d 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,8 +179,8 @@ function createSelectorBackend(params: SelectorRuntimeDeviceParams): AgentDevice boundOperations === undefined ? undefined : createSelectorCaptureRuntime({ + ref: params.ref, device, - session, sessionStore, sessionName, req, diff --git a/src/daemon/selector-runtime.ts b/src/daemon/selector-runtime.ts index 13ea73daa6..11ff2fdc99 100644 --- a/src/daemon/selector-runtime.ts +++ b/src/daemon/selector-runtime.ts @@ -66,7 +66,7 @@ export async function dispatchFindReadOnlyViaRuntime( }); recordIfSession( params.sessionStore, - params.sessionName, + resolvedRuntime.ref, req, buildFindRecordResult(result, action), ); @@ -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 }; } @@ -168,7 +168,7 @@ export async function dispatchGetViaRuntime( }); recordIfSession( params.sessionStore, - params.sessionName, + resolvedRuntime.ref, req, buildGetRecordResult(result, sub), { @@ -236,7 +236,7 @@ export async function dispatchIsViaRuntime( }); const recordedTarget = readRecordedResolutionTarget(result); const strippedResult = stripResolutionPayload(result); - recordIfSession(params.sessionStore, params.sessionName, req, strippedResult, recordedTarget); + recordIfSession(params.sessionStore, resolvedRuntime.ref, req, strippedResult, recordedTarget); return stripSelectorChain(strippedResult); }); return withCaptureDisclosures({ diff --git a/src/daemon/server/daemon-idle-reap.test.ts b/src/daemon/server/daemon-idle-reap.test.ts index 2efee9bd79..92832ac32f 100644 --- a/src/daemon/server/daemon-idle-reap.test.ts +++ b/src/daemon/server/daemon-idle-reap.test.ts @@ -54,12 +54,12 @@ test('resolveDaemonIdleReapMs ignores invalid overrides', () => { test('hasReapBlockingOpenSessions reflects the session store', () => { assert.equal(hasReapBlockingOpenSessions(sessionStore), false); - sessionStore.set('default', makeSession()); + sessionStore.publish('default', makeSession()); assert.equal(hasReapBlockingOpenSessions(sessionStore), true); }); test('hasActiveRecording is true only when a stored session carries a recording', () => { - sessionStore.set('default', makeSession()); + sessionStore.publish('default', makeSession()); assert.equal(hasActiveRecording(sessionStore), false); const recordingSession = makeSession(); @@ -69,7 +69,9 @@ test('hasActiveRecording is true only when a stored session carries a recording' startedAt: Date.now(), showTouches: false, }); - sessionStore.set('default', recordingSession); + sessionStore.update(sessionStore.lookup('default')!, { + screenRecording: recordingSession.screenRecording, + }); assert.equal(hasActiveRecording(sessionStore), true); }); @@ -77,7 +79,7 @@ test('isDaemonIdle requires no in-flight requests, no sessions, and no recording assert.equal(isDaemonIdle({ sessionStore, inFlightRequestCount: 0 }), true); assert.equal(isDaemonIdle({ sessionStore, inFlightRequestCount: 1 }), false); - sessionStore.set('default', makeSession()); + sessionStore.publish('default', makeSession()); assert.equal(isDaemonIdle({ sessionStore, inFlightRequestCount: 0 }), false); }); @@ -86,7 +88,7 @@ test('isDaemonIdle requires no in-flight requests, no sessions, and no recording // reaped (with a tombstone) rather than pinning the daemon forever. --- test('a repair-armed, un-committed session does NOT block idle-reap', () => { - sessionStore.set( + sessionStore.publish( 'default', makeSession({ scriptPublication: { @@ -102,7 +104,7 @@ test('a repair-armed, un-committed session does NOT block idle-reap', () => { }); test('a repair-armed session that has already COMMITTED still blocks idle-reap (until close deletes it)', () => { - sessionStore.set( + sessionStore.publish( 'default', makeSession({ scriptPublication: { @@ -118,13 +120,13 @@ test('a repair-armed session that has already COMMITTED still blocks idle-reap ( }); test('an ordinary (non-repair) open session still blocks idle-reap', () => { - sessionStore.set('default', makeSession()); + sessionStore.publish('default', makeSession()); assert.equal(hasReapBlockingOpenSessions(sessionStore), true); assert.equal(isDaemonIdle({ sessionStore, inFlightRequestCount: 0 }), false); }); test('a normal session alongside a reapable repair session still blocks idle-reap', () => { - sessionStore.set( + sessionStore.publish( 'default', makeSession({ name: 'default', @@ -136,7 +138,7 @@ test('a normal session alongside a reapable repair session still blocks idle-rea }, }), ); - sessionStore.set('other', makeSession({ name: 'other' })); + sessionStore.publish('other', makeSession({ name: 'other' })); assert.equal(hasReapBlockingOpenSessions(sessionStore), true); assert.equal(isDaemonIdle({ sessionStore, inFlightRequestCount: 0 }), false); }); @@ -164,7 +166,7 @@ test('idle reap fires after the idle window when nothing is using the daemon', a test('idle reap does not fire while a session is open', async () => { vi.useFakeTimers(); let reaped = 0; - sessionStore.set('default', makeSession()); + sessionStore.publish('default', makeSession()); const idleReap = createDaemonIdleReap({ sessionStore, getInFlightRequestCount: () => 0, @@ -192,7 +194,7 @@ test('idle reap does not fire while a recording is active', async () => { startedAt: Date.now(), showTouches: false, }); - sessionStore.set('default', recordingSession); + sessionStore.publish('default', recordingSession); const idleReap = createDaemonIdleReap({ sessionStore, getInFlightRequestCount: () => 0, diff --git a/src/daemon/server/daemon-runtime-device-claims.test.ts b/src/daemon/server/daemon-runtime-device-claims.test.ts index 97586767cc..86c91fb19f 100644 --- a/src/daemon/server/daemon-runtime-device-claims.test.ts +++ b/src/daemon/server/daemon-runtime-device-claims.test.ts @@ -36,7 +36,7 @@ function setup(): { session: SessionState; sessionStore: SessionStore; stateDir: actions: [], }; const sessionStore = new SessionStore(path.join(stateDir, 'sessions')); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); return { session, sessionStore, stateDir }; } @@ -47,7 +47,7 @@ test('finalizes provider state but does not clear a claim after shutdown teardow const afterSuccessfulTeardown = vi.fn(async () => {}); await teardownDaemonSessionForShutdown({ - session, + ref: sessionStore.lookup(session.name)!, sessionStore, stderr: { write: () => {} }, beforeDelete, @@ -67,7 +67,7 @@ test('finalizes provider state but does not clear a claim after shutdown teardow const afterSuccessfulTeardown = vi.fn(async () => {}); const teardown = teardownDaemonSessionForShutdown({ - session, + ref: sessionStore.lookup(session.name)!, sessionStore, stderr: { write: () => {} }, beforeDelete, diff --git a/src/daemon/server/daemon-runtime-lifecycle-shutdown.test.ts b/src/daemon/server/daemon-runtime-lifecycle-shutdown.test.ts index 1b39bd2745..74eec23d50 100644 --- a/src/daemon/server/daemon-runtime-lifecycle-shutdown.test.ts +++ b/src/daemon/server/daemon-runtime-lifecycle-shutdown.test.ts @@ -46,7 +46,9 @@ vi.mock('../../provider-device-runtimes.ts', () => ({ createDaemonProviderRuntimeComposition: async () => ({ runtimes: [], platformModules: [] }), })); -import { startDaemonRuntime } from './daemon-runtime.ts'; +import { startDaemonRuntime, teardownDaemonSessionForShutdown } from './daemon-runtime.ts'; +import { makeIosSession } from '../../__tests__/test-utils/session-factories.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; afterEach(() => { lifecycleEvents.length = 0; @@ -108,3 +110,48 @@ test('a SIGTERM shutdown gives the handoff a diagnostics scope to write its reas fs.rmSync(stateDir, { recursive: true, force: true }); } }); + +test.each([false, true])( + 'shutdown forwards only the addressed lifetime’s runtime hints, retired=%s', + async (retired) => { + const sessionStore = makeSessionStore('shutdown-scoped-hints-'); + const address = 'cwd:shutdown-hints:default'; + const ref = sessionStore.publish(address, makeIosSession('default')); + const publicRef = sessionStore.publish('default', makeIosSession('default')); + sessionStore.setRuntimeHints(address, { metroHost: 'scoped.example', metroPort: 8082 }); + sessionStore.setRuntimeHints('default', { metroHost: 'public.example', metroPort: 8083 }); + let successor; + if (retired) { + sessionStore.retire(ref); + successor = sessionStore.publish(address, makeIosSession('default')); + sessionStore.setRuntimeHints(address, { metroHost: 'successor.example', metroPort: 8084 }); + } + const finalizeApplicationLifecycle = vi.fn(async () => {}); + + await teardownDaemonSessionForShutdown({ + ref, + sessionStore, + stderr: { write: () => {} }, + finalizeApplicationLifecycle, + }); + + expect(finalizeApplicationLifecycle).toHaveBeenCalledWith( + ref.session, + retired ? {} : { metroHost: 'scoped.example', metroPort: '8082' }, + ); + expect(sessionStore.resolveCurrent(publicRef)).toBe(publicRef.session); + expect(sessionStore.getRuntimeHints('default')).toEqual({ + metroHost: 'public.example', + metroPort: 8083, + }); + if (successor) { + expect(sessionStore.resolveCurrent(successor)).toBe(successor.session); + expect(sessionStore.getRuntimeHints(address)).toEqual({ + metroHost: 'successor.example', + metroPort: 8084, + }); + } else { + expect(sessionStore.lookup(address)).toBeUndefined(); + } + }, +); diff --git a/src/daemon/server/daemon-runtime-recording-teardown.test.ts b/src/daemon/server/daemon-runtime-recording-teardown.test.ts index d0d9897465..1bdf9c6495 100644 --- a/src/daemon/server/daemon-runtime-recording-teardown.test.ts +++ b/src/daemon/server/daemon-runtime-recording-teardown.test.ts @@ -31,11 +31,11 @@ test('daemon shutdown awaits durable recording finalization inside its extended }), ); const session = makeRecordingSession({ name: 'shutdown-recording', sessionStore, finish }); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const stderrChunks: string[] = []; const teardown = teardownDaemonSessionForShutdown({ - session, + ref: sessionStore.lookup(session.name)!, sessionStore, stderr: { write: (chunk) => stderrChunks.push(chunk) }, }); @@ -68,11 +68,11 @@ test('daemon shutdown resolves durable recording resources through the effective }), }); session.name = 'default'; - sessionStore.set(effectiveSessionName, session); + sessionStore.publish(effectiveSessionName, session); const stderrChunks: string[] = []; await teardownDaemonSessionForShutdown({ - session, + ref: sessionStore.lookup(effectiveSessionName)!, sessionStore, stateDir: root, stderr: { write: (chunk) => stderrChunks.push(chunk) }, 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-web-cleanup.test.ts b/src/daemon/server/daemon-runtime-web-cleanup.test.ts index 69459e22e5..0e20e81ea1 100644 --- a/src/daemon/server/daemon-runtime-web-cleanup.test.ts +++ b/src/daemon/server/daemon-runtime-web-cleanup.test.ts @@ -32,7 +32,7 @@ test('daemon-startup web cleanup passes open web sessions to the reaper', async try { await installFakeManagedAgentBrowser(stateDir); const sessionStore = new SessionStore(path.join(stateDir, 'sessions')); - sessionStore.set('web-session', { + sessionStore.publish('web-session', { name: 'web-session', device: WEB_DESKTOP_DEVICE, createdAt: Date.now(), diff --git a/src/daemon/server/daemon-runtime-web-close-teardown.test.ts b/src/daemon/server/daemon-runtime-web-close-teardown.test.ts index dbd971d386..1613a97b87 100644 --- a/src/daemon/server/daemon-runtime-web-close-teardown.test.ts +++ b/src/daemon/server/daemon-runtime-web-close-teardown.test.ts @@ -31,7 +31,7 @@ test('daemon shutdown awaits a slow web close inside its extended budget', async await installFakeManagedAgentBrowser(root); const sessionStore = new SessionStore(path.join(root, 'sessions')); const session = makeWebSession('shutdown-slow-web-session'); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); mockRunCmd.mockImplementation( async () => await new Promise((resolve) => { @@ -49,7 +49,7 @@ test('daemon shutdown awaits a slow web close inside its extended budget', async const stderrChunks: string[] = []; const teardown = teardownDaemonSessionForShutdown({ - session, + ref: sessionStore.lookup(session.name)!, sessionStore, stateDir: root, stderr: { write: (chunk) => stderrChunks.push(chunk) }, @@ -74,7 +74,7 @@ test('daemon shutdown closes an open web session immediately, without waiting fo // Teardown runs while the session it is tearing down is still in the store (session deletion // happens after), which is also what keeps agent-browser's provider-startup orphan sweep from // treating this session's own browser as an orphan and scanning real host processes for it. - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); mockRunCmd.mockResolvedValue({ stdout: JSON.stringify({ success: true, data: {} }), stderr: '', @@ -83,7 +83,7 @@ test('daemon shutdown closes an open web session immediately, without waiting fo const stderrChunks: string[] = []; await teardownDaemonSessionForShutdown({ - session, + ref: sessionStore.lookup(session.name)!, sessionStore, stateDir: root, stderr: { write: (chunk) => stderrChunks.push(chunk) }, @@ -107,7 +107,7 @@ test('daemon shutdown reports a web close failure on stderr instead of losing it await installFakeManagedAgentBrowser(root); const sessionStore = new SessionStore(path.join(root, 'sessions')); const session = makeWebSession('shutdown-web-close-failure-session'); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); mockRunCmd.mockResolvedValue({ stdout: JSON.stringify({ success: false, error: 'no active browser session' }), stderr: '', @@ -116,7 +116,7 @@ test('daemon shutdown reports a web close failure on stderr instead of losing it const stderrChunks: string[] = []; await teardownDaemonSessionForShutdown({ - session, + ref: sessionStore.lookup(session.name)!, sessionStore, stateDir: root, stderr: { write: (chunk) => stderrChunks.push(chunk) }, diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index e561d56290..4f9f2f64b4 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -35,7 +35,8 @@ import { finalizeDaemonSessionApplicationLifecycle } from '../application-lifecy import { runtimeHintValues } from '../session-runtime.ts'; import { closeDaemonServers } from './server-shutdown.ts'; import type { DaemonInvokeFn } from '../daemon-request.ts'; -import type { SessionState } from '../session-state.ts'; +import type { SessionRef, SessionState } from '../session-state.ts'; +import type { RuntimeHintValues } from '@agent-device/contracts/application-lifecycle-runtime'; import { createDaemonIdleReap } from './daemon-idle-reap.ts'; import { createSessionIdleExpiry } from './daemon-session-idle-expiry.ts'; import { resolveSessionIdleExpiryMs } from '../session-idle-expiry.ts'; @@ -133,16 +134,19 @@ async function settleDaemonTeardownStep(params: { * silently swallowed. */ export async function teardownDaemonSessionForShutdown(params: { - session: SessionState; + ref: SessionRef; sessionStore: SessionStore; stateDir?: string; stderr: WritableOutput; - finalizeApplicationLifecycle?: (session: SessionState) => Promise; + finalizeApplicationLifecycle?: ( + session: SessionState, + runtimeHints: RuntimeHintValues, + ) => Promise; beforeDelete?: (session: SessionState) => Promise; afterSuccessfulTeardown?: (session: SessionState) => Promise; }): Promise { const { - session, + ref, sessionStore, stateDir, stderr, @@ -150,7 +154,11 @@ export async function teardownDaemonSessionForShutdown(params: { beforeDelete, afterSuccessfulTeardown, } = params; - const sessionName = sessionStore.resolveStoredSessionName(session); + const current = sessionStore.resolveCurrent(ref); + const session = current ?? ref.session; + const runtimeHints = runtimeHintValues( + current ? sessionStore.getRuntimeHints(ref.address) : undefined, + ); const timeoutMs = resolveDaemonSessionTeardownTimeoutMs(session); // The ownership-fenced app-log side effect must settle while this process // still owns the daemon lock. It is intentionally outside the generic @@ -160,9 +168,9 @@ export async function teardownDaemonSessionForShutdown(params: { session, stderr, resource: 'app-log', - teardown: async () => await stopSessionAppLog({ session, sessionName, sessionStore }), + teardown: async () => await stopSessionAppLog({ ref, sessionStore }), }); - const sessionAfterAppLog = sessionStore.get(sessionName) ?? session; + const sessionAfterAppLog = sessionStore.resolveCurrent(ref) ?? session; const teardown = (async () => { const genericTeardownSucceeded = await settleDaemonTeardownStep({ session, @@ -171,8 +179,7 @@ export async function teardownDaemonSessionForShutdown(params: { teardown: async () => await teardownSessionResources({ appLog: 'already-settled', - session: sessionAfterAppLog, - sessionName, + ref, sessionStore, stateDir, platformCleanup: platformResourceCleanup, @@ -183,7 +190,8 @@ export async function teardownDaemonSessionForShutdown(params: { session, stderr, resource: 'lifecycle', - teardown: async () => await finalizeApplicationLifecycle(sessionAfterAppLog), + teardown: async () => + await finalizeApplicationLifecycle(sessionAfterAppLog, runtimeHints), }) : true; return genericTeardownSucceeded && lifecycleTeardownSucceeded; @@ -199,10 +207,10 @@ 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.delete(sessionName); + sessionStore.retire(ref); } export type DaemonRuntimeOptions = { @@ -457,19 +465,20 @@ export async function startDaemonRuntime( const shutdownClaimLedger = createDaemonShutdownClaimLedger(); - const teardownDaemonSession = async (session: SessionState): Promise => { + const teardownDaemonSession = async (ref: SessionRef): Promise => { + const session = sessionStore.resolveCurrent(ref) ?? ref.session; try { await teardownDaemonSessionForShutdown({ - session, + ref, sessionStore, stderr, - finalizeApplicationLifecycle: async (sessionToFinalize) => + finalizeApplicationLifecycle: async (sessionToFinalize, runtimeHints) => await finalizeDaemonSessionApplicationLifecycle({ gateway: deviceRuntimeGateway, scope: createDaemonRecoveryPlatformScope(), session: sessionToFinalize, stateDir: baseDir, - runtimeHints: runtimeHintValues(sessionStore.getRuntimeHints(sessionToFinalize.name)), + runtimeHints, }), beforeDelete: async (sessionToFinalize) => { await finalizeDaemonSessionLease({ @@ -487,7 +496,7 @@ export async function startDaemonRuntime( }; const teardownDaemonSessions = async (): Promise => { - const sessionsToStop = sessionStore.toArray(); + const sessionsToStop = sessionStore.listRefs(); await Promise.all(sessionsToStop.map(teardownDaemonSession)); }; @@ -499,14 +508,13 @@ 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 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', - session, - sessionName, + ref, sessionStore, stateDir: baseDir, platformCleanup: platformResourceCleanup, @@ -516,7 +524,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. @@ -754,6 +762,7 @@ export async function startDaemonRuntime( sessionIdleExpiry.cancel(); if (shuttingDown) return; shuttingDown = true; + sessionStore.closeAdmission(); stopMetadataLossWatch(); if (shutdownOptions.cause) { await emitFatalDiagnostic(shutdownOptions.cause); 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..2780321e94 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, @@ -316,14 +316,10 @@ test('a session that moved to another device is fenced by that device, not the s }); const command = withKeyedLock(locks, 'session:default', async () => { await commandHeld; - sessionStore.set( - 'default', - makeIosSession('default', { - createdAt: NOW - WINDOW_MS - 1, - device: { ...IOS_SIMULATOR, id: 'sim-2', name: 'iPhone 17' }, - deviceClaim: { ...CLAIM, deviceKey: 'ios:sim-2' }, - }), - ); + sessionStore.update(sessionStore.lookup('default')!, { + device: { ...IOS_SIMULATOR, id: 'sim-2', name: 'iPhone 17' }, + deviceClaim: { ...CLAIM, deviceKey: 'ios:sim-2' }, + }); }); // A command on the NEW session's device is in flight the whole time. @@ -454,14 +450,14 @@ test('a settle that outruns its wait still finishes the release it started', asy test('a daemon beginning to leave does not start settling the next session', async () => { const { sessionStore } = makeFixture('agent-device-idle-expiry-closing-'); - sessionStore.set( + sessionStore.publish( 'first', makeIosSession('first', { createdAt: NOW - WINDOW_MS - 1, deviceClaim: { ...CLAIM, deviceKey: 'ios:sim-1' }, }), ); - sessionStore.set( + sessionStore.publish( 'second', makeIosSession('second', { createdAt: NOW - WINDOW_MS - 1, @@ -479,7 +475,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..350a31f022 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, @@ -234,7 +234,7 @@ test('a session closed by its owner stops the retry clock tracking it', async () await sweeps.swept(); assert.equal(settleCalls, 1); - sessionStore.delete('default'); + sessionStore.retire(sessionStore.lookup('default')!); await runUntilIdle(controller, 10); assert.equal(settleCalls, 1, 'nothing is left to retry once the session is gone'); controller.cancel(); @@ -243,7 +243,7 @@ test('a session closed by its owner stops the retry clock tracking it', async () test('a settled session of another kind is never touched by the sweep', async () => { const { sessionStore } = makeFixture('agent-device-idle-expiry-kind-'); // Past its deadline in time, but holding a remote lease: that lease owns the device. - sessionStore.set( + sessionStore.publish( 'leased', makeIosSession('leased', { createdAt: NOW - WINDOW_MS - 1, @@ -258,7 +258,7 @@ test('a settled session of another kind is never touched by the sweep', async () ); // Past its deadline too, but holding no claim: there is no device for another agent to wait on, // so there is nothing here an expiry could reclaim and nothing to release. - sessionStore.set('plain', unclaimedExpiredSession('plain')); + sessionStore.publish('plain', unclaimedExpiredSession('plain')); const settledNames: string[] = []; const controller = createSessionIdleExpiry({ @@ -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, @@ -292,7 +292,8 @@ test('a held-back settle leaves a repair transaction uncommitted, so a later pas createdAt: NOW - WINDOW_MS - 1, deviceClaim: { ...deviceClaim }, }); - sessionStore.set('default', session); + sessionStore.retire(sessionStore.lookup('default')!); + sessionStore.publish('default', session); const claimsDir = path.dirname(resolveDeviceClaimPath(deviceClaim.deviceKey)); const sweeps = createSweepBarrier(); @@ -353,7 +354,8 @@ test('a committed expiry finalizes the repair transaction it ends', async () => createdAt: NOW - WINDOW_MS - 1, deviceClaim: { ...deviceClaim }, }); - sessionStore.set('default', session); + sessionStore.retire(sessionStore.lookup('default')!); + sessionStore.publish('default', session); const sweeps = createSweepBarrier(); const controller = createSessionIdleExpiry({ @@ -376,3 +378,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-action-recorder.ts b/src/daemon/session-action-recorder.ts index 3335c3bc2a..3300d2d7b0 100644 --- a/src/daemon/session-action-recorder.ts +++ b/src/daemon/session-action-recorder.ts @@ -3,7 +3,7 @@ import type { CommandFlags } from '@agent-device/contracts/command'; import { recordedFlagKeys } from '@agent-device/command-registry/flag-registry'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import type { DaemonRequest } from './daemon-request.ts'; -import type { SessionRuntimeHints, SessionState } from './session-state.ts'; +import type { SessionRef, SessionRuntimeHints, SessionState } from './session-state.ts'; import { applyRecordedSaveScriptFlags } from './session-script-publication-capability.ts'; import { repairSessionBoundary } from './session-replay-transaction.ts'; import type { MultiTargetAnnotationV1, TargetAnnotationV1 } from '@agent-device/contracts/replay'; @@ -98,24 +98,24 @@ export function recordActionEntry( return action; } -type SessionActionStore = { recordAction(session: SessionState, entry: RecordActionEntry): void }; +type SessionActionStore = { recordAction(ref: SessionRef, entry: RecordActionEntry): void }; /** - * Record a session action if a session is active. No-op when session is undefined. + * Record an action in its admitted lifetime. No-op when no session was admitted. * * By default the recorded positionals/flags mirror the request; pass `overrides` to * record a different set (e.g. resolved positionals or stripped public flags). */ export function recordSessionAction( sessionStore: SessionActionStore, - session: SessionState | undefined, + ref: SessionRef | undefined, req: DaemonRequest, command: string, result: Record | undefined, overrides?: { positionals?: string[]; flags?: CommandFlags }, ): void { - if (!session) return; - sessionStore.recordAction(session, { + if (!ref) return; + sessionStore.recordAction(ref, { command, positionals: overrides?.positionals ?? req.positionals ?? [], flags: overrides?.flags ?? ((req.flags ?? {}) as CommandFlags), diff --git a/src/daemon/session-artifact-paths.ts b/src/daemon/session-artifact-paths.ts index d456d4d199..b445ed1ec1 100644 --- a/src/daemon/session-artifact-paths.ts +++ b/src/daemon/session-artifact-paths.ts @@ -1,6 +1,7 @@ import path from 'node:path'; import type { DiagnosticsRecordRef } from '@agent-device/kernel/errors'; -import { safeSessionName } from '@agent-device/host-kit/session-paths'; +import { AppError } from '@agent-device/kernel/errors'; +import { isSafeSessionSegment, safeSessionName } from '@agent-device/host-kit/session-paths'; /** Path to session-scoped platform subprocess output, such as Apple runner xcodebuild logs. */ export function resolveSessionRunnerLogPath(sessionDir: string): string { @@ -49,3 +50,27 @@ export function resolveRemoteRequestDiagnosticsPath( ref.requestId, ); } + +/** + * The one place a session name becomes a directory, so the invariant that every + * session dir lies beneath `sessionsDir` is enforced here rather than by each + * caller: `.` and `..` survive `safeSessionName` and would resolve to the + * sessions dir itself or the daemon state dir above it. + */ +export function resolveSessionDir(sessionsDir: string, sessionName: string): string { + if (!isSafeSessionSegment(sessionName)) { + throw new AppError( + 'INVALID_ARGS', + `Invalid session name ${JSON.stringify(sessionName)}: a session name cannot be empty, ".", or "..".`, + ); + } + return path.join(sessionsDir, safeSessionName(sessionName)); +} + +export function resolveSessionAppLogPath(sessionsDir: string, address: string): string { + return path.join(resolveSessionDir(sessionsDir, address), 'app.log'); +} + +export function resolveSessionAppLogPidPath(sessionsDir: string, address: string): string { + return path.join(resolveSessionDir(sessionsDir, address), 'app-log.pid'); +} diff --git a/src/daemon/session-capture-binding.ts b/src/daemon/session-capture-binding.ts new file mode 100644 index 0000000000..97555f0b93 --- /dev/null +++ b/src/daemon/session-capture-binding.ts @@ -0,0 +1,87 @@ +import type { + DurableCaptureSessionBinding, + DurableCaptureSessionResource, +} from '@agent-device/capture-kit/durable-capture'; +import { AppError } from '@agent-device/kernel/errors'; +import type { SessionRef, SessionState } from './session-state.ts'; +import type { SessionStore } from './session-store.ts'; + +export function bindSessionCapture( + sessionStore: SessionStore, + ref: SessionRef, + slot: Readonly<{ + read(session: SessionState): DurableCaptureSessionResource | undefined; + write(resource: DurableCaptureSessionResource | undefined): void; + }>, +): DurableCaptureSessionBinding { + let retained = slot.read(sessionStore.resolveCurrent(ref) ?? ref.session); + const assertAdoptable = (): void => { + sessionStore.assertAdmissionOpen(ref.address); + if (slot.read(sessionStore.requireCurrent(ref))) { + throw new AppError('COMMAND_FAILED', 'Session capture resource has changed', { + reason: 'session_resource_changed', + session: ref.address, + }); + } + }; + return Object.freeze({ + address: ref.address, + sessionDir: sessionStore.resolveSessionDir(ref.address), + read: () => { + const current = sessionStore.resolveCurrent(ref); + if (current) retained = slot.read(current); + return retained; + }, + assertAdoptable, + canPersist: () => { + const current = sessionStore.resolveCurrent(ref); + return current !== undefined && slot.read(current) === undefined; + }, + adopt: (resource) => { + assertAdoptable(); + slot.write(resource); + retained = resource; + }, + clear: (expected) => { + const current = sessionStore.resolveCurrent(ref); + if (!current) return 'retired'; + const active = slot.read(current); + if ( + active?.handle !== expected.handle || + active.envelope.fence.token !== expected.envelope.fence.token || + active.envelope.fence.generation !== expected.envelope.fence.generation + ) + return 'resource-changed'; + slot.write(undefined); + retained = undefined; + return 'cleared'; + }, + }); +} + +export function bindSessionAudioProbe(sessionStore: SessionStore, ref: SessionRef) { + return bindSessionCapture(sessionStore, ref, { + read: (session) => session.audioProbe, + write: (audioProbe) => { + sessionStore.update(ref, { audioProbe }); + }, + }); +} + +export function bindSessionPerfCapture(sessionStore: SessionStore, ref: SessionRef) { + return bindSessionCapture(sessionStore, ref, { + read: (session) => session.perfCapture, + write: (perfCapture) => { + sessionStore.update(ref, { perfCapture }); + }, + }); +} + +export function bindSessionScreenRecording(sessionStore: SessionStore, ref: SessionRef) { + return bindSessionCapture(sessionStore, ref, { + read: (session) => session.screenRecording, + write: (screenRecording) => { + sessionStore.update(ref, { screenRecording }); + }, + }); +} diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-capabilities-install-projection.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-capabilities-install-projection.test.ts index 07dd92244b..8b64cca232 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-capabilities-install-projection.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-capabilities-install-projection.test.ts @@ -142,7 +142,7 @@ test('capabilities fixture preserves transport mode and owner-specific refusal r function createAndroidCapabilitiesSession(suffix: string) { const sessionName = `android-capabilities-${suffix}`; const sessionStore = makeSessionStore(`agent-device-capabilities-${suffix}-`); - sessionStore.set(sessionName, makeAndroidSession(sessionName)); + sessionStore.publish(sessionName, makeAndroidSession(sessionName)); return { sessionName, sessionStore }; } diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-capabilities.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-capabilities.test.ts index d6ff281cb3..c02e9d3e85 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-capabilities.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-capabilities.test.ts @@ -49,7 +49,7 @@ function assertAndroidCapabilityHonesty(availableCommands: unknown): void { /** The one interaction-capable Android owner both projection tests below read. */ async function projectAndroidCapabilities(sessionName: string) { const sessionStore = makeSessionStore('agent-device-capabilities-'); - sessionStore.set(sessionName, makeAndroidSession(sessionName)); + sessionStore.publish(sessionName, makeAndroidSession(sessionName)); const runtime = createAdmissionRuntime({ appLogAvailable: true, ensureReadyAvailable: true, @@ -121,7 +121,7 @@ test('capabilities omits the commands this Android owner does not admit', async test('capabilities excludes logs from an unavailable provider-mode XCTest runtime fact', async () => { const sessionName = 'provider-xctest-capabilities'; const sessionStore = makeSessionStore('agent-device-capabilities-provider-xctest-'); - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { name: sessionName, device: { platform: 'apple', @@ -176,7 +176,7 @@ test('capabilities excludes logs from an unavailable provider-mode XCTest runtim test('capabilities excludes network when the runtime fact is unavailable', async () => { const sessionName = 'android-capabilities-no-network'; const sessionStore = makeSessionStore('agent-device-capabilities-no-network-'); - sessionStore.set(sessionName, makeAndroidSession(sessionName)); + sessionStore.publish(sessionName, makeAndroidSession(sessionName)); const runtime = createAdmissionRuntime({ appLogAvailable: true, networkAvailable: false, @@ -217,7 +217,7 @@ test('capabilities includes apps for the available HarmonyOS runtime fact', asyn target: 'mobile', booted: true, } as const; - sessionStore.set(sessionName, makeSession(sessionName, { device: harmonyDevice })); + sessionStore.publish(sessionName, makeSession(sessionName, { device: harmonyDevice })); const runtime = createAdmissionRuntime({ appLogAvailable: true, networkAvailable: false, @@ -298,7 +298,7 @@ test.each(APPS_UNAVAILABLE_CAPABILITY_CASES)( async ({ label, device, providerMode }) => { const sessionName = `capabilities-${label.toLowerCase().replaceAll(' ', '-')}`; const sessionStore = makeSessionStore(`agent-device-capabilities-${label}-`); - sessionStore.set(sessionName, makeSession(sessionName, { device })); + sessionStore.publish(sessionName, makeSession(sessionName, { device })); const runtime = createAdmissionRuntime({ appLogAvailable: false, networkAvailable: false, @@ -332,7 +332,7 @@ test.each(APPS_UNAVAILABLE_CAPABILITY_CASES)( test('capabilities excludes appstate when its runtime fact is unavailable', async () => { const sessionName = 'android-capabilities-no-appstate'; const sessionStore = makeSessionStore('agent-device-capabilities-no-appstate-'); - sessionStore.set(sessionName, makeAndroidSession(sessionName)); + sessionStore.publish(sessionName, makeAndroidSession(sessionName)); const runtime = createAdmissionRuntime({ appLogAvailable: true, appStateAvailable: false, @@ -364,7 +364,7 @@ test('capabilities excludes appstate when its runtime fact is unavailable', asyn test('capabilities excludes appstate when its readiness fact is unavailable', async () => { const sessionName = 'android-capabilities-no-readiness'; const sessionStore = makeSessionStore('agent-device-capabilities-no-readiness-'); - sessionStore.set(sessionName, makeAndroidSession(sessionName)); + sessionStore.publish(sessionName, makeAndroidSession(sessionName)); const runtime = createAdmissionRuntime({ appLogAvailable: true, appStateAvailable: true, @@ -424,7 +424,7 @@ test.each([ async (_name, device) => { const sessionName = device.id + '-session'; const sessionStore = makeSessionStore('agent-device-capabilities-' + device.id + '-'); - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { name: sessionName, device, createdAt: Date.now(), diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-close-cwd-scoped-resources.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-close-cwd-scoped-resources.test.ts index 9918c3aa1d..002a466fc7 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-close-cwd-scoped-resources.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-close-cwd-scoped-resources.test.ts @@ -75,7 +75,7 @@ test('close finishes the recording of a cwd-scoped session by store address', as const sessionStore = makeSessionStore(); const session = makeIosSimulatorRecordingSession(sessionStore, ADDRESS, { name: NAME }); const finish = recordingFinishMock(session); - sessionStore.set(ADDRESS, session); + sessionStore.publish(ADDRESS, session); const resourcePath = screenRecordingResourceStore.resolvePath( sessionStore.resolveSessionDir(ADDRESS), ); @@ -86,7 +86,7 @@ test('close finishes the recording of a cwd-scoped session by store address', as device: { ...IOS_SIM, id: 'decoy-udid', name: 'iPhone 15' }, }); const decoyFinish = recordingFinishMock(decoy); - sessionStore.set(NAME, decoy); + sessionStore.publish(NAME, decoy); const decoyResourcePath = screenRecordingResourceStore.resolvePath( sessionStore.resolveSessionDir(NAME), ); @@ -143,7 +143,7 @@ test('close stops the app log of a cwd-scoped session by store address', async ( }, metadata: { phase: 'active' }, }); - sessionStore.set(ADDRESS, { + sessionStore.publish(ADDRESS, { ...makeSession(NAME, IOS_SIM), appBundleId: 'com.apple.Preferences', appLog: { handle, envelope }, @@ -215,7 +215,10 @@ test('close finishes the audio probe of a cwd-scoped session by store address', marker: { pid: 4242, startTime: 'boot+1', command: 'helper' }, }), }); - sessionStore.set(ADDRESS, { ...makeSession(NAME, IOS_SIM), audioProbe: { handle, envelope } }); + sessionStore.publish(ADDRESS, { + ...makeSession(NAME, IOS_SIM), + audioProbe: { handle, envelope }, + }); const resourcePath = audioProbeResourceStore.resolvePath(sessionDir); audioProbeResourceStore.write(resourcePath, envelope); @@ -255,7 +258,10 @@ test('close stops the perf capture of a cwd-scoped session by store address', as lifecycle: 'open', descriptor: { version: 1, body: { kind: 'fixture' } }, }); - sessionStore.set(ADDRESS, { ...makeSession(NAME, IOS_SIM), perfCapture: { handle, envelope } }); + sessionStore.publish(ADDRESS, { + ...makeSession(NAME, IOS_SIM), + perfCapture: { handle, envelope }, + }); const resourcePath = perfCaptureResourceStore.resolvePath(sessionDir); perfCaptureResourceStore.write(resourcePath, envelope); diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-close-error-precedence.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-close-error-precedence.test.ts index 3d2019eb61..ae89f86624 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-close-error-precedence.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-close-error-precedence.test.ts @@ -38,7 +38,7 @@ test('targeted close preserves the platform-close AppError and still runs later booted: true, }, }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const platformCloseError = new AppError('DEVICE_UNAVAILABLE', 'platform close failed', { reason: 'device_disconnected', @@ -111,7 +111,7 @@ test('a failed platform close retains the device claim and reports it', async () } const session = makeIosSimulatorRecordingSession(sessionStore, sessionName, { device }); session.deviceClaim = acquired.ownership; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const platformCloseError = new AppError('DEVICE_UNAVAILABLE', 'platform close failed', { reason: 'device_disconnected', @@ -228,7 +228,7 @@ test('a failing best-effort cleanup also retains the device claim and reports it ? Envelope : never, }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); // The platform close itself succeeds; only the best-effort cleanup step fails, so the // blocking error arrives as the cleanup aggregate rather than as platformCloseError. @@ -321,7 +321,7 @@ test('a successful close clears the device claim', async () => { ...makeSession(sessionName, device), deviceClaim: acquired.ownership, }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await handleSessionCommands({ req: { @@ -362,7 +362,7 @@ test('targeted close skips platform dispatch and preserves the error when the re }), appBundleId: 'com.example.app', } as unknown as SessionState; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const preCloseError = new AppError('RUNNER_UNAVAILABLE', 'runner stop failed', { reason: 'runner_stop_failed', 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..58f8e46dcc 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 @@ -89,7 +89,7 @@ test('an app-only close without a target fails before admission or ref-frame exp }; const session = makeSession(sessionName, device); activateCompleteRefFrame(session); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const revisionBeforeClose = readSessionRuntimeRevision(session); const response = await close({ @@ -120,7 +120,7 @@ test('close rejects a false close-target fact before its one implementation bind kind: 'emulator' as const, booted: true, }; - sessionStore.set(sessionName, makeSession(sessionName, device)); + sessionStore.publish(sessionName, makeSession(sessionName, device)); mockInspectDeviceRuntimeFacts.mockImplementationOnce(async (candidate) => { const facts = lifecycleRuntimeFacts(candidate); const unavailable = { @@ -163,7 +163,7 @@ test('a close-capable runtime with false runtime-hints facts never loads the run }; const session = makeSession(sessionName, device); session.appBundleId = 'com.example.harmony'; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockInspectDeviceRuntimeFacts.mockImplementationOnce(async (candidate) => { const facts = lifecycleRuntimeFacts(candidate); return { @@ -205,7 +205,7 @@ test('an app-only close with stored hints selects no runtime-hint cleanup siblin }; const session = makeSession(sessionName, device); session.appBundleId = 'com.example.app'; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); sessionStore.setRuntimeHints(sessionName, { platform: 'android', metroHost: '10.0.2.2' }); mockInspectDeviceRuntimeFacts.mockImplementationOnce(async (candidate) => { const facts = lifecycleRuntimeFacts(candidate); @@ -254,7 +254,7 @@ test('a close with persisted native hints fails closed when the distinct cleanup }; const session = makeSession(sessionName, device); session.appBundleId = 'com.example.app'; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); sessionStore.setRuntimeHints(sessionName, { platform: 'ios', metroHost: '10.0.2.2' }); mockInspectDeviceRuntimeFacts.mockImplementationOnce(async (candidate) => { const facts = lifecycleRuntimeFacts(candidate); @@ -296,7 +296,7 @@ test('a supported Android close clears admitted runtime hints exactly once', asy }; const session = makeSession(sessionName, device); session.appBundleId = 'com.example.app'; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); sessionStore.setRuntimeHints(sessionName, { platform: 'android', metroHost: '10.0.2.2', @@ -332,7 +332,7 @@ test('close expires the ref frame immediately before its admitted platform mutat }; const session = makeSession(sessionName, device); session.appBundleId = 'com.example.app'; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockDispatch.mockImplementationOnce(async () => { expect(refFrameState(session)).toBe('expired'); }); @@ -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..d366a41a62 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 @@ -39,7 +39,7 @@ test('close stops Android snapshot helper session before deleting session', asyn kind: 'emulator', booted: true, }; - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, device), appBundleId: 'com.example.app', }); @@ -133,7 +133,7 @@ test('close stops active host audio probe before deleting session', async () => }), audioProbe: { handle, envelope }, }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); audioProbeResourceStore.write( audioProbeResourceStore.resolvePath(sessionStore.resolveSessionDir(sessionName)), envelope, @@ -161,7 +161,7 @@ test('close stops active host audio probe before deleting session', async () => test('close dispatches web session cleanup without a positional target', async () => { const sessionStore = makeSessionStore(); const sessionName = 'web-close-session'; - sessionStore.set(sessionName, makeSession(sessionName, WEB_DESKTOP_DEVICE)); + sessionStore.publish(sessionName, makeSession(sessionName, WEB_DESKTOP_DEVICE)); const response = await handleSessionCommands({ req: { @@ -199,7 +199,7 @@ test('close preserves the session and lease when provider release fails so it ca deviceKey: 'ios:bs-device', clientId: 'client-a', }); - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, WEB_DESKTOP_DEVICE), lease: { leaseId: lease.leaseId, @@ -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-save-script.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-close-save-script.test.ts index 6c4c5a933a..68d45ca903 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-close-save-script.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-close-save-script.test.ts @@ -32,7 +32,7 @@ test('close --save-script on a never-armed session is rejected before teardown, // teardown to observably touch. const session = makeIosSimulatorRecordingSession(sessionStore, sessionName); const finish = recordingFinishMock(session); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); await expect( handleSessionCommands({ @@ -106,7 +106,7 @@ test('close --save-script on a session with an active .ad repair transaction is boundary: 0, }, }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await handleSessionCommands({ req: { 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/__tests__/session-close-shutdown-platform.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-close-shutdown-platform.test.ts index ae4e4ec1fa..63cdbda0e4 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-close-shutdown-platform.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-close-shutdown-platform.test.ts @@ -52,7 +52,7 @@ function expectShutdownFailure( test('close --shutdown calls shutdownSimulator for iOS simulator and includes result in response', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-shutdown-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { platform: 'apple', @@ -111,7 +111,7 @@ test('close --shutdown keeps a selected provider-owned iOS simulator off local s kind: 'simulator' as const, booted: true, }; - sessionStore.set(sessionName, makeSession(sessionName, device)); + sessionStore.publish(sessionName, makeSession(sessionName, device)); const providerClose = vi.fn(async () => undefined); const providerFinalize = vi.fn(async () => ({})); mockInspectDeviceRuntimeFacts.mockImplementationOnce(async (candidate) => { @@ -159,7 +159,7 @@ test('close --shutdown keeps a selected provider-owned iOS simulator off local s test('close --shutdown calls shutdownAndroidEmulator for Android emulator and includes result in response', async () => { const sessionStore = makeSessionStore(); const sessionName = 'android-shutdown-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { platform: 'android', @@ -209,7 +209,7 @@ test('close --shutdown calls shutdownAndroidEmulator for Android emulator and in test('close --shutdown is ignored for non-simulator iOS devices', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-device-shutdown-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { platform: 'apple', @@ -247,7 +247,7 @@ test('close --shutdown is ignored for non-simulator iOS devices', async () => { test('close --shutdown is ignored for Android devices', async () => { const sessionStore = makeSessionStore(); const sessionName = 'android-device-shutdown-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { platform: 'android', @@ -285,7 +285,7 @@ test('close --shutdown is ignored for Android devices', async () => { test('close --shutdown returns success and failure payload when shutdownAndroidEmulator throws', async () => { const sessionStore = makeSessionStore(); const sessionName = 'android-shutdown-failure-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { platform: 'android', @@ -325,7 +325,7 @@ test('close --shutdown returns success and failure payload when shutdownAndroidE test('close --shutdown returns success and failure payload when shutdownSimulator throws', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-shutdown-failure-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { platform: 'apple', diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-devices-batch-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-devices-batch-runtime.test.ts index bbf3f549e7..52563445af 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-devices-batch-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-devices-batch-runtime.test.ts @@ -358,7 +358,7 @@ test('batch step pins nested requests to the resolved session', async () => { test('close clears retained materialized install paths bound to the session', async () => { const sessionStore = makeSessionStore('agent-device-devices-batch-runtime-'); const sessionName = 'materialized-close-active'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-inventory-apps-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-inventory-apps-runtime.test.ts index 9e54b04cb9..81d4f960a5 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-inventory-apps-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-inventory-apps-runtime.test.ts @@ -104,7 +104,7 @@ beforeEach(() => { test('macOS apps consumes generic readiness and app inventory through one runtime bind', async () => { const sessionName = 'macos-apps'; const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, makeSession(sessionName, MACOS_DEVICE)); + sessionStore.publish(sessionName, makeSession(sessionName, MACOS_DEVICE)); const req: DaemonRequest = { token: 'test-token', session: sessionName, diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-inventory-harmonyos.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-inventory-harmonyos.test.ts index 9ef2745297..ad184e7641 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-inventory-harmonyos.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-inventory-harmonyos.test.ts @@ -98,7 +98,7 @@ beforeEach(() => { async function listApps(appsFilter: 'all' | 'user-installed'): Promise { const sessionName = 'harmony-apps'; const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, makeSession(sessionName, HARMONY_DEVICE)); + sessionStore.publish(sessionName, makeSession(sessionName, HARMONY_DEVICE)); const req: DaemonRequest = { token: 'test-token', session: sessionName, diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-inventory-scoped-paths.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-inventory-scoped-paths.test.ts index 824e0758b3..a6a2683972 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-inventory-scoped-paths.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-inventory-scoped-paths.test.ts @@ -27,7 +27,7 @@ function scopedSession(): SessionState { async function runSessionList(): Promise { const sessionStore = makeSessionStore('agent-device-inventory-scoped-'); - sessionStore.set(SCOPED_KEY, scopedSession()); + sessionStore.publish(SCOPED_KEY, scopedSession()); const req: DaemonRequest = { token: 't', session: 'default', @@ -54,30 +54,30 @@ test('session list returns the caller cwd and local named sessions without cross }; const callerScope = resolveImplicitSessionScope(req)!; const callerSessionAddress = `cwd:${callerScope.id}:default`; - sessionStore.set(callerSessionAddress, { + sessionStore.publish(callerSessionAddress, { ...scopedSession(), sessionScope: callerScope, }); - sessionStore.set('qa-cart-integrity', { + sessionStore.publish('qa-cart-integrity', { ...scopedSession(), name: 'qa-cart-integrity', sessionScope: { kind: 'named-local' }, }); - sessionStore.set('tenant-a:qa', { + sessionStore.publish('tenant-a:qa', { ...scopedSession(), name: 'tenant-a:qa', sessionScope: { kind: 'named-local' }, }); - sessionStore.set('default', { + sessionStore.publish('default', { ...scopedSession(), sessionScope: { kind: 'global-default' }, }); - sessionStore.set('cwd:other:default', { + sessionStore.publish('cwd:other:default', { ...scopedSession(), sessionScope: { kind: 'cwd', id: 'other' }, }); const tenantSessionAddress = tenantScopedSessionName('tenant-a', 'remote-recording'); - sessionStore.set(tenantSessionAddress, { + sessionStore.publish(tenantSessionAddress, { ...scopedSession(), name: tenantSessionAddress, sessionScope: { kind: 'tenant', id: 'tenant-a' }, @@ -103,14 +103,14 @@ test('session list returns only sessions owned by the requesting tenant', async const sessionStore = makeSessionStore('agent-device-inventory-tenant-'); for (const tenantId of ['tenant-a', 'tenant-b']) { const address = tenantScopedSessionName(tenantId, 'default'); - sessionStore.set(address, { + sessionStore.publish(address, { ...scopedSession(), name: address, sessionScope: { kind: 'tenant', id: tenantId }, }); } for (const address of ['qa-cart-integrity', 'tenant-a:qa']) { - sessionStore.set(address, { + sessionStore.publish(address, { ...scopedSession(), name: address, sessionScope: { kind: 'named-local' }, diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-device-in-use.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-device-in-use.test.ts index db7ff7ad8d..f9aad9365e 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-device-in-use.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-device-in-use.test.ts @@ -1,3 +1,4 @@ +import { makeStoredSessionRef } from '../../../../__tests__/test-utils/store-factory.ts'; import { test, expect } from 'vitest'; import { buildDeviceInUseBySessionError, @@ -13,16 +14,16 @@ import { IOS_SIMULATOR } from '../../../../__tests__/test-utils/device-fixtures. const SCOPED_ADDRESS = 'cwd:8bea844ab16aa9b3:default'; -const scopedRef: SessionRef = { - address: SCOPED_ADDRESS, - session: { +const scopedRef: SessionRef = makeStoredSessionRef( + { name: 'default', sessionScope: { kind: 'cwd', id: '8bea844ab16aa9b3' }, device: IOS_SIMULATOR, createdAt: 0, actions: [], }, -}; + SCOPED_ADDRESS, +); test('the by-session conflict reports the address, in the message, details and hint', () => { const response = buildDeviceInUseBySessionError(scopedRef, IOS_SIMULATOR); @@ -41,16 +42,16 @@ test('the by-session conflict reports the address, in the message, details and h // --session, nor close. test('the foreign-workspace conflict names the owning session address', () => { const foreignAddress = 'cwd:1d9b7c2f4a6e8b03:default'; - const foreignRef: SessionRef = { - address: foreignAddress, - session: { + const foreignRef: SessionRef = makeStoredSessionRef( + { name: 'default', sessionScope: { kind: 'cwd', id: '1d9b7c2f4a6e8b03' }, device: IOS_SIMULATOR, createdAt: 0, actions: [], }, - }; + foreignAddress, + ); const response = buildForeignWorkspaceSessionConflict(foreignRef, IOS_SIMULATOR); diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts index 095d95cf90..8d05648d5d 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts @@ -1,11 +1,17 @@ import { test, expect, vi, beforeEach } from 'vitest'; import path from 'node:path'; +import fs from 'node:fs'; import type { ApplicationLifecycleRuntimeOperations, OpenApplicationInput, } from '@agent-device/contracts/application-lifecycle-runtime'; import type { DaemonRequest } from '../../../daemon-request.ts'; +import { + registerRequestAbort, + markRequestCanceled, + clearRequestAbortRegistration, +} from '@agent-device/host-kit/request'; import { AppError } from '@agent-device/kernel/errors'; const mockResolveTargetDevice = vi.hoisted(() => vi.fn()); @@ -180,7 +186,7 @@ test('open runtime payload clears stale applied transport hints before launch', metroHost: '10.0.0.10', metroPort: 8081, }); - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, makeAndroidEmulator()), appBundleId: 'com.example.demo', appName: 'Demo', @@ -436,3 +442,211 @@ test('open reports the launch confirmation its platform answered', async () => { expect(response?.ok).toBe(true); if (response?.ok) expect(response.data?.launchConfirmation).toBe('accepted'); }); + +function holdNativeOpen() { + let entered!: () => void; + let release!: () => void; + const reached = new Promise((resolve) => { + entered = resolve; + }); + const held = new Promise((resolve) => { + release = resolve; + }); + const bindDefault = mockBindDeviceRuntime.getMockImplementation(); + if (!bindDefault) throw new Error('the harness binds a default runtime'); + mockBindDeviceRuntime.mockImplementationOnce(async (device, use) => { + const binding = await bindDefault(device, use); + const operations = binding.operations as ApplicationLifecycleRuntimeOperations; + return { + ...binding, + operations: { + ...binding.operations, + openApplication: async (input: OpenApplicationInput) => { + entered(); + await held; + return await operations.openApplication(input); + }, + }, + }; + }); + return { reached, release }; +} + +function invokeHeldOpen( + sessionStore: ReturnType, + req?: Partial, +) { + return handleSessionCommands({ + req: { + token: 't', + command: 'open', + session: 'default', + positionals: ['com.example.demo'], + flags: { platform: 'android' }, + ...req, + }, + sessionName: 'cwd:held-open:default', + sessionStore, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + invoke: noopInvoke, + }); +} + +test('reopen publishes into the latest record of the same lifetime after native launch', async () => { + const store = makeSessionStore(); + const session = makeSession('default', makeAndroidEmulator()); + const ref = store.publish('cwd:held-open:default', session); + const native = holdNativeOpen(); + const pending = invokeHeldOpen(store); + await native.reached; + store.update(ref, { recordOnlySession: true }); + native.release(); + expect((await pending)?.ok).toBe(true); + expect(store.lookup(ref.address)?.lifetime).toBe(ref.lifetime); + expect(store.requireCurrent(ref).recordOnlySession).toBe(true); + expect(store.requireCurrent(ref).createdAt).toBe(session.createdAt); + expect(store.requireCurrent(ref).name).toBe('default'); +}); + +test('a retired reopen cannot write hints or actions to the same record republished as a successor', async () => { + const store = makeSessionStore(); + const session = makeSession('default', makeAndroidEmulator()); + const ref = store.publish('cwd:held-open:default', session); + const native = holdNativeOpen(); + const pending = invokeHeldOpen(store, { runtime: { metroHost: 'new-host', metroPort: 9000 } }); + const refusal = expect(pending).rejects.toThrow( + expect.objectContaining({ + details: expect.objectContaining({ reason: 'session_lifetime_ended' }), + }), + ); + await native.reached; + store.retire(ref); + const successor = store.publish(ref.address, session); + store.setRuntimeHints(ref.address, { platform: 'android', metroHost: 'successor-host' }); + native.release(); + await refusal; + expect(store.requireCurrent(successor)).toBe(session); + expect(session.actions).toEqual([]); + expect(store.getRuntimeHints(ref.address)?.metroHost).toBe('successor-host'); +}); + +test('a provisional open refuses a successor before native launch', async () => { + const store = makeSessionStore(); + const session = makeSession('default', makeAndroidEmulator()); + const ref = store.publish('cwd:held-open:default', session); + await expect( + invokeHeldOpen(store, { + internal: { + openLifecycle: { + beforeDispatch: async () => { + const provisional = store.requireCurrent(ref); + store.retire(ref); + store.publish(ref.address, provisional); + }, + }, + }, + }), + ).rejects.toThrow( + expect.objectContaining({ + details: expect.objectContaining({ reason: 'session_lifetime_ended' }), + }), + ); + expect(mockDispatch).not.toHaveBeenCalled(); + expect(store.get(ref.address)?.actions).toEqual([]); +}); + +test('fresh open completing after shutdown cannot publish or replace runtime hints', async () => { + const store = makeSessionStore(); + mockResolveTargetDevice.mockResolvedValue(makeAndroidEmulator('emulator-shutdown-open')); + const native = holdNativeOpen(); + const pending = invokeHeldOpen(store, { runtime: { metroHost: 'new-host', metroPort: 9000 } }); + const refusal = expect(pending).rejects.toThrow( + expect.objectContaining({ + details: expect.objectContaining({ reason: 'daemon_shutting_down' }), + }), + ); + await native.reached; + store.closeAdmission(); + native.release(); + await refusal; + expect(store.get('cwd:held-open:default')).toBeUndefined(); + expect(store.getRuntimeHints('cwd:held-open:default')).toBeUndefined(); +}); + +test('cancelled fresh open does not publish after native launch returns', async () => { + const store = makeSessionStore(); + mockResolveTargetDevice.mockResolvedValue(makeAndroidEmulator('emulator-cancelled-open')); + const native = holdNativeOpen(); + const requestId = 'cancelled-open-lifetime'; + const registration = registerRequestAbort(requestId); + try { + const pending = invokeHeldOpen(store, { meta: { requestId } }); + await native.reached; + markRequestCanceled(requestId); + native.release(); + expect(await pending).toEqual( + expect.objectContaining({ + ok: false, + error: expect.objectContaining({ + code: 'COMMAND_FAILED', + details: expect.objectContaining({ reason: 'request_canceled' }), + }), + }), + ); + expect(store.get('cwd:held-open:default')).toBeUndefined(); + } finally { + clearRequestAbortRegistration(registration); + } +}); + +test('fresh open does not publish when its artifact directory cannot be created', async () => { + const store = makeSessionStore(); + mockResolveTargetDevice.mockResolvedValue(makeAndroidEmulator('emulator-directory-failed-open')); + fs.writeFileSync(path.dirname(store.resolveSessionDir('cwd:held-open:default')), 'blocked'); + await expect( + invokeHeldOpen(store, { runtime: { metroHost: 'new-host', metroPort: 9000 } }), + ).rejects.toMatchObject({ code: 'ENOTDIR' }); + expect(mockDispatch).toHaveBeenCalled(); + expect(store.get('cwd:held-open:default')).toBeUndefined(); + expect(store.getRuntimeHints('cwd:held-open:default')).toBeUndefined(); +}); + +for (const transition of ['rebuild', 'retire'] as const) { + test(`foreground composition retains the published lifetime across ${transition}`, async () => { + const store = makeSessionStore(); + mockResolveTargetDevice.mockResolvedValue( + makeAndroidEmulator(`emulator-foreground-${transition}`), + ); + const record = store.recordAction.bind(store); + vi.spyOn(store, 'recordAction').mockImplementationOnce((recordedRef, entry) => { + record(recordedRef, entry); + const ref = store.lookup('cwd:held-open:default')!; + queueMicrotask(() => { + if (transition === 'rebuild') { + store.update(ref, { recordOnlySession: true }); + } else { + store.retire(ref); + store.publish(ref.address, recordedRef.session); + } + mockInspectDeviceRuntimeFacts.mockClear(); + }); + }); + const response = await invokeHeldOpen(store, { + flags: { platform: 'android', foreground: true }, + }); + expect(response?.ok).toBe(true); + if (!response?.ok) throw new Error('open must remain successful'); + if (transition === 'retire') { + expect(response.data?.initialSnapshotError).toEqual( + expect.objectContaining({ + details: expect.objectContaining({ reason: 'session_lifetime_ended' }), + }), + ); + expect(mockInspectDeviceRuntimeFacts).not.toHaveBeenCalled(); + expect(store.get('cwd:held-open:default')?.snapshot).toBeUndefined(); + } else { + expect(mockInspectDeviceRuntimeFacts).toHaveBeenCalled(); + expect(store.get('cwd:held-open:default')?.recordOnlySession).toBe(true); + } + }); +} diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-existing.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-existing.test.ts index 554d189172..e0d28abc70 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-existing.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-existing.test.ts @@ -20,7 +20,7 @@ import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts' test('open web URL on iOS device session without active app falls back to Safari', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-device-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { platform: 'apple', @@ -62,7 +62,7 @@ test('open web URL on iOS device session without active app falls back to Safari test('open web URL on iOS simulator session without active app falls back to Safari', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-simulator-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { platform: 'apple', @@ -112,7 +112,7 @@ test('open web URL on iOS simulator session without active app falls back to Saf test('open app and URL on existing iOS device session keeps app context', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-device-session'; - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, { platform: 'apple', id: 'ios-device-1', @@ -159,7 +159,7 @@ test('open app and URL on existing iOS device session keeps app context', async test('open app on existing macOS session resolves and stores bundle id', async () => { const sessionStore = makeSessionStore(); const sessionName = 'macos-session'; - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, { platform: 'apple', appleOs: 'macos', @@ -234,7 +234,7 @@ test('open rejects --surface on non-macOS devices', async () => { test('open on existing macOS frontmost-app session preserves surface without --surface flag', async () => { const sessionStore = makeSessionStore(); const sessionName = 'macos-frontmost-existing'; - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, { platform: 'apple', appleOs: 'macos', @@ -295,7 +295,7 @@ test('open on existing macOS frontmost-app session preserves surface without --s test('open on existing iOS session refreshes unavailable simulator by name', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-session'; - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, { platform: 'apple', id: 'stale-sim', @@ -361,7 +361,7 @@ test('open on existing iOS session refreshes unavailable simulator by name', asy test('open app on existing Android session resolves and stores package id', async () => { const sessionStore = makeSessionStore(); const sessionName = 'android-session'; - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, { platform: 'android', id: 'emulator-5554', @@ -404,7 +404,7 @@ test('open app on existing Android session resolves and stores package id', asyn test('open intent target on existing Android session clears stale package context', async () => { const sessionStore = makeSessionStore(); const sessionName = 'android-session'; - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, { platform: 'android', id: 'emulator-5554', @@ -455,7 +455,7 @@ test('open on existing Android session preserves a comparable freshness baseline type: 'android.widget.TextView', label: `Inbox row ${index + 1}`, })); - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, { platform: 'android', id: 'emulator-5554', diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-foreground.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-foreground.test.ts index a2039a45f6..7794912797 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-foreground.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-foreground.test.ts @@ -4,6 +4,11 @@ const dispatchSnapshotViaRuntime = vi.hoisted(() => vi.fn()); vi.mock('../../../snapshot-runtime.ts', () => ({ dispatchSnapshotViaRuntime })); +import { + makeSessionStore, + makeSession, + makeAndroidEmulator, +} from './session-open-runtime.fixtures.ts'; import { AppError } from '@agent-device/kernel/errors'; import type { DaemonRequest, DaemonResponse } from '../../../daemon-request.ts'; import { @@ -13,6 +18,8 @@ import { const inspectFacts = vi.fn(); const bindDevice = vi.fn(); +let sessionStore: ReturnType; +let ref: ReturnType; function baseRequest(overrides: Partial = {}): DaemonRequest { return { @@ -26,6 +33,8 @@ function baseRequest(overrides: Partial = {}): DaemonRequest { } beforeEach(() => { + sessionStore = makeSessionStore(); + ref = sessionStore.publish('default', makeSession('default', makeAndroidEmulator())); dispatchSnapshotViaRuntime.mockReset(); inspectFacts.mockReset(); bindDevice.mockReset(); @@ -142,9 +151,9 @@ const failedOpenResponse: DaemonResponse = { test('passes a failed open response through untouched', async () => { const result = await composeOpenWithInitialSnapshot({ req: baseRequest({ flags: { foreground: true } }), - sessionName: 'default', + ref, logPath: '/tmp/daemon.log', - sessionStore: {} as never, + sessionStore, openResponse: failedOpenResponse, inspectFacts, bindDevice, @@ -157,9 +166,9 @@ test('passes a failed open response through untouched', async () => { test('leaves a successful open response untouched when --foreground was not requested', async () => { const result = await composeOpenWithInitialSnapshot({ req: baseRequest(), - sessionName: 'default', + ref, logPath: '/tmp/daemon.log', - sessionStore: {} as never, + sessionStore, openResponse: okOpenResponse, inspectFacts, bindDevice, @@ -175,9 +184,9 @@ test('attaches the initial INTERACTIVE snapshot by delegating to the existing sn const req = baseRequest({ flags: { foreground: true }, positionals: ['xyz.blueskyweb.app'] }); const result = await composeOpenWithInitialSnapshot({ req, - sessionName: 'default', + ref, logPath: '/tmp/daemon.log', - sessionStore: {} as never, + sessionStore, openResponse: okOpenResponse, inspectFacts, bindDevice, @@ -193,8 +202,9 @@ test('attaches the initial INTERACTIVE snapshot by delegating to the existing sn flags: { ...req.flags, snapshotInteractiveOnly: true }, }, sessionName: 'default', + sessionRef: ref, logPath: '/tmp/daemon.log', - sessionStore: {}, + sessionStore, inspectFacts, bindDevice, }); @@ -224,9 +234,9 @@ test('a snapshot-capture failure never masks the successful open', async () => { const result = await composeOpenWithInitialSnapshot({ req: baseRequest({ flags: { foreground: true } }), - sessionName: 'default', + ref, logPath: '/tmp/daemon.log', - sessionStore: {} as never, + sessionStore, openResponse: { ok: true, data: { session: 'default', warnings: ['pre-existing warning'] } }, inspectFacts, bindDevice, @@ -266,9 +276,9 @@ test('a THROWN snapshot-capture failure never masks the successful open either', const result = await composeOpenWithInitialSnapshot({ req: baseRequest({ flags: { foreground: true } }), - sessionName: 'default', + ref, logPath: '/tmp/daemon.log', - sessionStore: {} as never, + sessionStore, openResponse: { ok: true, data: { session: 'default' } }, inspectFacts, bindDevice, diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-state.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-state.test.ts new file mode 100644 index 0000000000..cd48fa95c5 --- /dev/null +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-state.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { publishOpenSession } from '../session-open-state.ts'; +import { makeSessionStore } from './session-open-runtime.fixtures.ts'; +import { + authoringPublication, + makeIosSession, +} from '../../../../__tests__/test-utils/session-factories.ts'; +import { IOS_SIMULATOR } from '../../../../__tests__/test-utils/device-fixtures.ts'; +import { isSessionRecording } from '../../../session-script-publication-capability.ts'; + +// --- #1533: a re-open cannot resurrect a terminal authoring lifecycle --- +// +// This surface used to decide recording on its own (`existingSession.recordSession || saveScript`), +// which is how an ABORTED lifecycle came back to life on a third `open --save-script`. Recording is +// now derived from the lifecycle, so the only thing left to pin here is that a re-open carries the +// publication state through untouched — it has no arming decision to get wrong. + +function reopen(existingSession: ReturnType) { + const store = makeSessionStore(); + const ref = store.publish(existingSession.name, existingSession); + return store.requireCurrent( + publishOpenSession({ + req: { token: 't', command: 'open', positionals: [], session: existingSession.name }, + sessionStore: store, + sessionName: existingSession.name, + existingRef: ref, + device: IOS_SIMULATOR, + surface: 'app', + appBundleId: 'com.example.other', + }), + ); +} + +test('#1533: a re-open leaves an aborted authoring lifecycle aborted, and not recording', () => { + const aborted = makeIosSession('s', { scriptPublication: authoringPublication('aborted') }); + + const next = reopen(aborted); + + assert.deepEqual(next.scriptPublication, authoringPublication('aborted')); + assert.equal(isSessionRecording(next), false); +}); + +test('a re-open carries an armed authoring lifecycle through unchanged', () => { + const armed = makeIosSession('s', { scriptPublication: authoringPublication('armed') }); + + const next = reopen(armed); + + assert.deepEqual(next.scriptPublication, authoringPublication('armed')); + assert.equal(isSessionRecording(next), true); +}); + +test('a re-open of a session that never armed records nothing', () => { + const next = reopen(makeIosSession('s')); + + assert.equal(next.scriptPublication, undefined); + assert.equal(isSessionRecording(next), false); +}); diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-surface.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-surface.test.ts index 63d81782cb..12cb6b9f15 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-surface.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-surface.test.ts @@ -1,14 +1,9 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; -import { buildNextOpenSession, buildOpenResult } from '../session-open-surface.ts'; +import { buildOpenResult } from '../session-open-surface.ts'; import { resolveRequestedOpenSurface } from '../../../../platform-runtime-open-target.ts'; -import { - authoringPublication, - makeIosSession, -} from '../../../../__tests__/test-utils/session-factories.ts'; import { IOS_SIMULATOR } from '../../../../__tests__/test-utils/device-fixtures.ts'; -import { isSessionRecording } from '../../../session-script-publication-capability.ts'; test('resolveRequestedOpenSurface rejects surface flag on iOS', () => { assert.throws( @@ -103,46 +98,3 @@ test('buildOpenResult reports an answered launch confirmation and omits one that ); assert.equal('launchConfirmation' in buildOpenResult(base), false); }); - -// --- #1533: a re-open cannot resurrect a terminal authoring lifecycle --- -// -// This surface used to decide recording on its own (`existingSession.recordSession || saveScript`), -// which is how an ABORTED lifecycle came back to life on a third `open --save-script`. Recording is -// now derived from the lifecycle, so the only thing left to pin here is that a re-open carries the -// publication state through untouched — it has no arming decision to get wrong. - -function reopen(existingSession: ReturnType) { - return buildNextOpenSession({ - existingSession, - sessionName: existingSession.name, - sessionScope: existingSession.sessionScope ?? { kind: 'named-local' }, - device: IOS_SIMULATOR, - surface: 'app', - appBundleId: 'com.example.other', - }); -} - -test('#1533: a re-open leaves an aborted authoring lifecycle aborted, and not recording', () => { - const aborted = makeIosSession('s', { scriptPublication: authoringPublication('aborted') }); - - const next = reopen(aborted); - - assert.deepEqual(next.scriptPublication, authoringPublication('aborted')); - assert.equal(isSessionRecording(next), false); -}); - -test('a re-open carries an armed authoring lifecycle through unchanged', () => { - const armed = makeIosSession('s', { scriptPublication: authoringPublication('armed') }); - - const next = reopen(armed); - - assert.deepEqual(next.scriptPublication, authoringPublication('armed')); - assert.equal(isSessionRecording(next), true); -}); - -test('a re-open of a session that never armed records nothing', () => { - const next = reopen(makeIosSession('s')); - - assert.equal(next.scriptPublication, undefined); - assert.equal(isSessionRecording(next), false); -}); diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-url-prewarm.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-url-prewarm.test.ts index 96010f58fb..4a3aa559fc 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-url-prewarm.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-url-prewarm.test.ts @@ -137,7 +137,7 @@ function sessionRequest( test('open web URL on existing iOS simulator session binds Safari and drops stale app bundle id', async () => { const sessionStore = makeSessionStore('agent-device-session-open-url-prewarm-'); const sessionName = 'ios-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { @@ -179,7 +179,7 @@ test('open web URL on existing iOS simulator session binds Safari and drops stal test('open URL on existing macOS session clears stale app bundle id', async () => { const sessionStore = makeSessionStore('agent-device-session-open-url-prewarm-'); const sessionName = 'macos-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { @@ -216,7 +216,7 @@ test('open URL on existing macOS session clears stale app bundle id', async () = test('open URL on existing iOS device session preserves app bundle id context', async () => { const sessionStore = makeSessionStore('agent-device-session-open-url-prewarm-'); const sessionName = 'ios-device-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { @@ -251,7 +251,7 @@ test('open URL on existing iOS device session preserves app bundle id context', test('open custom URL on existing iOS simulator session preserves app bundle id context', async () => { const sessionStore = makeSessionStore('agent-device-session-open-url-prewarm-'); const sessionName = 'ios-simulator-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { @@ -364,7 +364,7 @@ test('open iOS simulator app prewarms runner cache during cold boot', async () = test('open iOS app session prewarms runner session when app bundle id is known', async () => { const sessionStore = makeSessionStore('agent-device-session-open-url-prewarm-'); const sessionName = 'ios-device-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { @@ -398,7 +398,7 @@ test('open iOS Maestro app link waits for runner prewarm before launching app', const sessionName = 'ios-maestro-open-link'; const events: string[] = []; let finishPrewarm: (() => void) | undefined; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { @@ -460,7 +460,7 @@ test('open iOS Maestro app link resets a simulator runner prewarmed before URL d kind: 'simulator' as const, booted: true, }; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device, @@ -499,7 +499,7 @@ test('open iOS Maestro app link resets a simulator runner prewarmed before URL d test('open iOS Maestro app link reports blocking runner prewarm failures before launching app', async () => { const sessionStore = makeSessionStore('agent-device-session-open-url-prewarm-'); const sessionName = 'ios-maestro-open-link-prewarm-failed'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { @@ -542,7 +542,7 @@ test('open iOS Maestro app link reports blocking runner prewarm failures before test('open iOS URL without app bundle id skips runner prewarm', async () => { const sessionStore = makeSessionStore('agent-device-session-open-url-prewarm-'); const sessionName = 'ios-device-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-teardown-resources.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-teardown-resources.test.ts index d2ffe590f8..5c3f52f660 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-teardown-resources.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-teardown-resources.test.ts @@ -38,7 +38,7 @@ test('close finalizes an active iOS simulator recording before deleting the sess const sessionName = 'ios-active-recording-close-session'; const session = makeIosSimulatorRecordingSession(sessionStore, sessionName); const finish = recordingFinishMock(session); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await handleSessionCommands({ req: { @@ -75,7 +75,7 @@ test('close surfaces a recording finalization failure through the cleanup-failur }); const finish = recordingFinishMock(session); const forceCleanup = recordingCleanupMock(session); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); await expect( handleSessionCommands({ @@ -105,18 +105,16 @@ test('daemon resource teardown finalizes recording before lifecycle runner dispo const sessionStore = makeSessionStore(); const session = makeIosSimulatorRecordingSession(sessionStore, sessionName); const finish = recordingFinishMock(session); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); await teardownSessionResources({ appLog: 'already-settled', - session, - sessionName, + ref: sessionStore.lookup(sessionName)!, sessionStore, }); await teardownSessionResources({ appLog: 'already-settled', - session, - sessionName, + ref: sessionStore.lookup(sessionName)!, sessionStore, }); @@ -140,13 +138,12 @@ test('daemon session teardown surfaces a recording finalization failure', async }); const finish = recordingFinishMock(session); const forceCleanup = recordingCleanupMock(session); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); await expect( teardownSessionResources({ appLog: 'already-settled', - session, - sessionName, + ref: sessionStore.lookup(sessionName)!, sessionStore, }), ).rejects.toThrow(/recording: .*failed to stop recording/); @@ -165,13 +162,12 @@ test('daemon session teardown retains recording evidence when finish and forced }); const finish = recordingFinishMock(session); const forceCleanup = recordingCleanupMock(session); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); await expect( teardownSessionResources({ appLog: 'already-settled', - session, - sessionName, + ref: sessionStore.lookup(sessionName)!, sessionStore, }), ).rejects.toThrow(/recording: .*failed to stop recording/); @@ -206,7 +202,8 @@ test('daemon session teardown stops Android snapshot helper session', async () = } as SessionState; const sessionStore = makeSessionStore(); - await teardownSessionResources({ appLog: 'already-settled', session, sessionName, sessionStore }); + const ref = sessionStore.publish(sessionName, session); + await teardownSessionResources({ appLog: 'already-settled', ref, sessionStore }); expect(mockStopAndroidSnapshotHelperSessionForDevice).toHaveBeenCalledWith(session.device); }); @@ -232,13 +229,12 @@ test('daemon session teardown attempts every resource after an earlier cleanup r // Perf cleanup runs before the snapshot-helper cleanup. const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); await expect( teardownSessionResources({ appLog: 'already-settled', - session, - sessionName, + ref: sessionStore.lookup(sessionName)!, sessionStore, }), ).rejects.toMatchObject({ @@ -283,10 +279,14 @@ test('daemon session teardown closes an open web session immediately, not on age // Teardown always runs while the session it is tearing down is still in the store (session // deletion happens after), which is what keeps the provider-startup orphan sweep from treating // this session's own browser as an orphan and scanning real host processes for it. - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockRunCmd.mockResolvedValue(agentBrowserJsonResult({ success: true, data: {} })); - await teardownSessionResources({ appLog: 'already-settled', session, sessionName, sessionStore }); + await teardownSessionResources({ + appLog: 'already-settled', + ref: sessionStore.lookup(sessionName)!, + sessionStore, + }); // A SIGTERM daemon shutdown (or an expired-session reap) tells agent-browser to close its // fleet right away, the same way an explicit `session close` does, instead of leaving the @@ -303,13 +303,17 @@ test('daemon session teardown surfaces a web close failure through the cleanup-f const sessionStore = makeSessionStore(); await installFakeManagedAgentBrowser(sessionStore.resolveDaemonStateDir()); const session = makeSession(sessionName, WEB_DESKTOP_DEVICE); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockRunCmd.mockResolvedValue( agentBrowserJsonResult({ success: false, error: 'no active browser session' }), ); await expect( - teardownSessionResources({ appLog: 'already-settled', session, sessionName, sessionStore }), + teardownSessionResources({ + appLog: 'already-settled', + ref: sessionStore.lookup(sessionName)!, + sessionStore, + }), ).rejects.toMatchObject({ code: 'COMMAND_FAILED', details: expect.objectContaining({ @@ -329,11 +333,12 @@ test('daemon session teardown never dispatches a web close for a non-web session booted: true, }); + const sessionStore = makeSessionStore(); + const ref = sessionStore.publish(sessionName, session); await teardownSessionResources({ appLog: 'already-settled', - session, - sessionName, - sessionStore: makeSessionStore(), + ref, + sessionStore, }); expect(mockRunCmd).not.toHaveBeenCalled(); 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 1ce108ec07..202996c16d 100644 --- a/src/daemon/session-lifecycle/internal/session-close-lifecycle-teardown.ts +++ b/src/daemon/session-lifecycle/internal/session-close-lifecycle-teardown.ts @@ -37,8 +37,7 @@ export type SessionCloseTeardownResult = Readonly<{ /** Runs owned resources, native close, native hint cleanup, and final lifecycle disposal. */ export async function runSessionCloseTeardown(params: { req: DaemonRequest; - session: SessionState; - sessionName: string; + ref: SessionRef; logPath: string; sessionStore: SessionStore; lifecycle: CloseRuntime | CloseRuntimeWithRuntimeHintClear; @@ -48,7 +47,7 @@ export async function runSessionCloseTeardown(params: { dispatchTargetedPlatformClose: PlatformCloseDispatcher; finalizeOrdinaryCloseScript(input: { req: DaemonRequest; - session: SessionState; + ref: SessionRef; sessionStore: SessionStore; platformCloseError: unknown; }): Error | undefined; @@ -56,8 +55,7 @@ export async function runSessionCloseTeardown(params: { }): Promise { const { req, - session, - sessionName, + ref, logPath, sessionStore, lifecycle, @@ -67,6 +65,8 @@ export async function runSessionCloseTeardown(params: { dispatchTargetedPlatformClose, finalizeOrdinaryCloseScript, } = params; + const { address: sessionName } = ref; + let session = sessionStore.requireCurrent(ref); const attemptCleanup = async ( step: string, run: () => Promise, @@ -86,11 +86,12 @@ export async function runSessionCloseTeardown(params: { }); const configuredRuntimeHints = sessionStore.getRuntimeHints(sessionName); await stopBestEffortSessionResources( - { address: sessionName, session }, + ref, sessionStore, 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), ); @@ -134,21 +135,11 @@ async function stopBestEffortSessionResources( attemptCleanup: CleanupRunner, platformCleanup: PlatformResourceCleanup, ): Promise { - const { address: sessionName, session } = ref; - // Recording overlay finalization needs the Apple runner, so it runs first. - // `finishSessionScreenRecording` re-reads the stored session by address and - // returns when there is no recording; a second lookup here would only be a - // place to mis-address it. - await attemptCleanup('recording', () => - finishSessionScreenRecording({ session, sessionName, sessionStore }), - ); - await attemptCleanup('app_log', () => stopSessionAppLog({ session, sessionName, sessionStore })); - await attemptCleanup('audio_probe', () => - finishSessionAudioProbe({ session, sessionName, sessionStore }), - ); - await attemptCleanup('perf_capture', () => - stopSessionPerfCapture({ session, sessionName, sessionStore }), - ); + const session = sessionStore.resolveCurrent(ref) ?? ref.session; + await attemptCleanup('recording', () => finishSessionScreenRecording({ ref, sessionStore })); + await attemptCleanup('app_log', () => stopSessionAppLog({ ref, sessionStore })); + await attemptCleanup('audio_probe', () => finishSessionAudioProbe({ ref, sessionStore })); + await attemptCleanup('perf_capture', () => stopSessionPerfCapture({ ref, sessionStore })); await attemptCleanup('platform_snapshot_helper', () => stopSessionSnapshotHelper(session, platformCleanup), ); diff --git a/src/daemon/session-lifecycle/internal/session-close-script.ts b/src/daemon/session-lifecycle/internal/session-close-script.ts index 47fbfc88f2..ea8c3a69a6 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, @@ -18,12 +18,9 @@ export type RepairCloseCommit = | { kind: 'aborted' } | { kind: 'failed'; error: AppError }; -function recordCloseAction( - sessionStore: SessionStore, - session: SessionState, - req: DaemonRequest, -): void { - sessionStore.recordAction(session, { +function recordCloseAction(sessionStore: SessionStore, ref: SessionRef, req: DaemonRequest): void { + const session = sessionStore.requireCurrent(ref); + sessionStore.recordAction(ref, { command: 'close', positionals: req.positionals ?? [], flags: (req.flags ?? {}) as CommandFlags, @@ -33,15 +30,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); + recordCloseAction(sessionStore, ref, 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,20 +75,21 @@ 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); + recordCloseAction(sessionStore, ref, req); } // The recorded close action already armed target/force through the recorder's flag ingress. // On a platform-close failure that action was never recorded, so the log still publishes to the // 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 889cc5016b..7654c1bc0e 100644 --- a/src/daemon/session-lifecycle/internal/session-close.ts +++ b/src/daemon/session-lifecycle/internal/session-close.ts @@ -2,7 +2,7 @@ import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { AppError, normalizeError } from '@agent-device/kernel/errors'; import type { LeaseLifecycleProvider, TargetShutdownResult } from '@agent-device/contracts/device'; import type { 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 { successText, withSuccessText } from '@agent-device/kernel/success-text'; import { resolveCommandDevice } from '../../session-device-resolution.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. @@ -193,8 +194,8 @@ export async function handleSessionCloseCommands( params: SessionCloseCommandInput, ): Promise { const { req, sessionName, logPath, sessionStore, leaseRegistry, leaseLifecycleProvider } = params; - const session = sessionStore.get(sessionName); - if (!session) { + const ref = sessionStore.lookup(sessionName); + if (!ref) { return await closeWithoutSession({ req, logPath, @@ -202,6 +203,7 @@ export async function handleSessionCloseCommands( bindDevice: params.bindDevice, }); } + let session = sessionStore.requireCurrent(ref); assertTerminalRecordingCloseAllowed(req, session); const app = req.positionals?.[0]; if (req.internal?.closeAppOnly === true && !app) { @@ -224,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); @@ -238,7 +241,7 @@ export async function handleSessionCloseCommands( } const repair = await prepareRepairClose({ req, - session, + ref, logPath, sessionStore, lifecycle: admission.runtime, @@ -246,8 +249,7 @@ export async function handleSessionCloseCommands( if ('response' in repair) return repair.response; const closed = await runCloseTeardownAndRelease({ req, - session, - sessionName, + ref, logPath, sessionStore, leaseRegistry, @@ -281,8 +283,7 @@ type SessionCloseFinalization = // lease release keeps the session retryable instead (`{kind:'response'}`). async function runCloseTeardownAndRelease(params: { req: DaemonRequest; - session: SessionState; - sessionName: string; + ref: SessionRef; logPath: string; sessionStore: SessionStore; leaseRegistry: LeaseRegistry; @@ -294,8 +295,7 @@ async function runCloseTeardownAndRelease(params: { }): Promise { const { req, - session, - sessionName, + ref, logPath, sessionStore, leaseRegistry, @@ -303,11 +303,11 @@ async function runCloseTeardownAndRelease(params: { lifecycle, clearRuntimeHints, } = params; + const { address: sessionName } = ref; const cleanupFailures: SessionCleanupFailure[] = []; const { platformCloseError, saveScriptError, shutdownResult } = await runSessionCloseTeardown({ req, - session, - sessionName, + ref, logPath, sessionStore, lifecycle, @@ -318,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) { @@ -340,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-lifecycle/internal/session-open-execution.ts b/src/daemon/session-lifecycle/internal/session-open-execution.ts index 82d04c6364..ef8a65fbc0 100644 --- a/src/daemon/session-lifecycle/internal/session-open-execution.ts +++ b/src/daemon/session-lifecycle/internal/session-open-execution.ts @@ -8,10 +8,10 @@ import { type DeviceSelectionResult, } from '@agent-device/device-selection/device-selection-resolver'; import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtime'; -import type { SessionScope, SessionSurface } from '@agent-device/contracts/session'; +import type { SessionSurface } from '@agent-device/contracts/session'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { DaemonRequest, DaemonResponse } from '../../daemon-request.ts'; -import type { SessionState } from '../../session-state.ts'; +import type { SessionRef, SessionState } from '../../session-state.ts'; import { abortAuthoringOnSecondOpen, armAuthoringOnOpen, @@ -30,7 +30,8 @@ import { setSessionRuntimeHintsForOpen, } from '../../session-runtime.ts'; import { STARTUP_SAMPLE_METHOD, type StartupPerfSample } from './session-startup-metrics.ts'; -import { buildNextOpenSession, buildOpenResult } from './session-open-surface.ts'; +import { buildOpenResult } from './session-open-surface.ts'; +import { publishOpenSession, requireOpenSessionAdmission } from './session-open-state.ts'; import { markDeferredInteractionOutcome } from '../../deferred-interaction-outcome.ts'; import { emitDiagnostic, getDiagnosticsMeta } from '@agent-device/host-kit/diagnostics'; import { @@ -42,12 +43,7 @@ import { buildForeignWorkspaceSessionConflict, } from '../../session-recovery-hints.ts'; import { describeOpenWaitForRefusal } from '../../open-device-contention-wait.ts'; -import { - isImplicitSessionScopeConflict, - resolveSessionScope, - resolvePublicSessionName, -} from '../../session-routing.ts'; -import { resolveSessionLeaseForRequest } from '../../lease-lifecycle.ts'; +import { isImplicitSessionScopeConflict, resolvePublicSessionName } from '../../session-routing.ts'; import { applicationLifecycleExecutionFromRequest } from '../../application-lifecycle-execution.ts'; import { abandonDeviceClaim, @@ -69,6 +65,10 @@ import { requireAllocatorHeldDeviceClaim } from '../../device/device-claim-alloc import { deviceClaimRuleForOwner } from '../../device/device-claim-rule.ts'; import { errorResponse, type DaemonFailureResponse } from '@agent-device/kernel/contracts'; +export type SessionOpenResult = + | Readonly<{ type: 'opened'; response: DaemonResponse; ref: SessionRef }> + | Readonly<{ type: 'response'; response: DaemonResponse }>; + type OpenTiming = { totalDurationMs?: number; relaunchCloseDurationMs?: number; @@ -92,10 +92,6 @@ export type RuntimeHintClearOperation = BoundDeviceRuntime< typeof openApplicationWithRuntimeHintClearUse >['operations']['clearRuntimeHints']; -function resolveOpenSessionScope(req: DaemonRequest): SessionScope { - return req.internal?.resolvedSessionScope ?? resolveSessionScope(req); -} - function applyOrdinaryScriptRecordingOpenOutcome(params: { session: SessionState; existingSession: SessionState | undefined; @@ -188,12 +184,12 @@ export async function completeOpenCommand(params: { runtimeHints: ReturnType; lifecycle: OpenApplicationRuntime; applyRuntimeHints?: RuntimeHintApplyOperation; - existingSession?: SessionState; + existingRef?: SessionRef; deviceClaim?: DeviceClaimSessionOwnership; /** The stale claim this open released before taking the device, when there was one. */ tookOverDeviceClaim?: TakenOverDeviceClaim; selection?: DeviceSelectionResult; -}): Promise { +}): Promise { const { req, sessionName, @@ -208,11 +204,14 @@ export async function completeOpenCommand(params: { runtimeHints, lifecycle, applyRuntimeHints, - existingSession, + existingRef, deviceClaim, tookOverDeviceClaim, selection, } = params; + requireOpenSessionAdmission(sessionStore, sessionName, existingRef); + const existingSession = existingRef ? sessionStore.requireCurrent(existingRef) : undefined; + const freshnessBaseline = existingSession?.snapshot; const shouldRelaunch = req.flags?.relaunch === true; let sessionAppBundleId = appBundleId; const openCommandStartedAtMs = Date.now(); @@ -225,10 +224,14 @@ export async function completeOpenCommand(params: { surface, sessionAppBundleId, appName, - existingSession, + existingRef, }); - if (provisionalSession.type === 'response') return provisionalSession.response; - const openDispatchSession = provisionalSession.session ?? existingSession; + if (provisionalSession.type === 'response') return provisionalSession; + const openDispatchRef = provisionalSession.ref; + requireOpenSessionAdmission(sessionStore, sessionName, openDispatchRef); + const openDispatchSession = openDispatchRef + ? sessionStore.requireCurrent(openDispatchRef) + : undefined; const openStartedAtMs = Date.now(); const outcome = await lifecycle.operations.openApplication({ target: openTarget, @@ -247,7 +250,7 @@ export async function completeOpenCommand(params: { execution: applicationLifecycleExecutionFromRequest( req, logPath, - existingSession?.trace?.outPath, + openDispatchSession?.trace?.outPath, ), }); sessionAppBundleId = outcome.appBundleId ?? sessionAppBundleId; @@ -258,35 +261,22 @@ export async function completeOpenCommand(params: { : undefined; if (isRequestCanceled(req.meta?.requestId)) { const canceled = createRequestCanceledError(); - return errorResponse(canceled.code, canceled.message, canceled.details); + return { + type: 'response', + response: errorResponse(canceled.code, canceled.message, canceled.details), + }; } - if (existingSession) { - // Mark before buildNextOpenSession clears the stored snapshot. `open` is one of the few - // nav-sensitive commands that would otherwise lose its pre-action freshness baseline. + requireOpenSessionAdmission(sessionStore, sessionName, openDispatchRef); + if (existingRef) { markDeferredInteractionOutcome({ - session: existingSession, + session: sessionStore.requireCurrent(existingRef), command: 'open', positionals: [], flags: undefined, + androidFreshnessBaseline: freshnessBaseline, }); } - const nextSession = buildNextOpenSession({ - existingSession: openDispatchSession, - sessionName: existingSession?.name ?? resolvePublicSessionName(req), - sessionScope: existingSession?.sessionScope ?? resolveOpenSessionScope(req), - device, - surface, - appBundleId: sessionAppBundleId, - appName, - }); - nextSession.lease = resolveSessionLeaseForRequest({ - req, - existingLease: existingSession?.lease, - }); - if (deviceClaim) nextSession.deviceClaim = deviceClaim; - if (req.runtime !== undefined) - setSessionRuntimeHintsForOpen(sessionStore, sessionName, runtimeHints); const sessionStateDir = sessionStore.ensureSessionDir(sessionName); const requestLogPath = resolveSessionRequestLogPath( sessionStateDir, @@ -300,7 +290,7 @@ export async function completeOpenCommand(params: { data: timing, }); const openResult = buildOpenResult({ - sessionName: nextSession.name, + sessionName: openDispatchSession?.name ?? resolvePublicSessionName(req), sessionStateDir, runnerLogPath: resolveSessionRunnerLogPath(sessionStateDir), requestLogPath, @@ -320,21 +310,34 @@ export async function completeOpenCommand(params: { if (tookOverDeviceClaim) { appendResponseWarning(openResult, deviceClaimTakeoverWarning(tookOverDeviceClaim)); } + const nextRef = publishOpenSession({ + req, + sessionStore, + sessionName, + existingRef: openDispatchRef, + device, + surface, + appBundleId: sessionAppBundleId, + appName, + deviceClaim, + }); + const nextSession = sessionStore.requireCurrent(nextRef); + if (req.runtime !== undefined) + setSessionRuntimeHintsForOpen(sessionStore, sessionName, runtimeHints); applyOrdinaryScriptRecordingOpenOutcome({ session: nextSession, - existingSession, + existingSession: existingRef ? nextSession : undefined, saveScriptRequested: Boolean(req.flags?.saveScript), responseData: openResult, }); - sessionStore.set(sessionName, nextSession); - sessionStore.recordAction(nextSession, { + sessionStore.recordAction(nextRef, { command: 'open', positionals: openPositionals, flags: req.flags ?? {}, runtime: req.runtime !== undefined ? runtimeHints : undefined, result: openResult, }); - return { ok: true, data: openResult }; + return { type: 'opened', ref: nextRef, response: { ok: true, data: openResult } }; } async function prepareOpenDispatchSession(params: { @@ -345,45 +348,28 @@ async function prepareOpenDispatchSession(params: { surface: SessionSurface; sessionAppBundleId: string | undefined; appName: string | undefined; - existingSession: SessionState | undefined; + existingRef: SessionRef | undefined; }): Promise< - { type: 'session'; session?: SessionState } | { type: 'response'; response: DaemonResponse } + { type: 'session'; ref?: SessionRef } | { type: 'response'; response: DaemonResponse } > { - const { req, sessionName, sessionStore, existingSession } = params; + const { req, sessionName, sessionStore, existingRef } = params; const beforeDispatch = req.internal?.openLifecycle?.beforeDispatch; - if (!beforeDispatch) return { type: 'session', session: existingSession }; - const provisionalSession = createProvisionalOpenDispatchSession(params); - sessionStore.set(sessionName, provisionalSession); + if (!beforeDispatch) return { type: 'session', ref: existingRef }; + const ref = publishOpenSession({ + req, + sessionName, + sessionStore, + existingRef, + device: params.device, + surface: params.surface, + appBundleId: params.sessionAppBundleId, + appName: params.appName, + }); const lifecycleResponse = await beforeDispatch(); if (lifecycleResponse && !lifecycleResponse.ok) return { type: 'response', response: lifecycleResponse }; - return { type: 'session', session: sessionStore.get(sessionName) ?? provisionalSession }; -} - -function createProvisionalOpenDispatchSession(params: { - req: DaemonRequest; - sessionName: string; - device: DeviceInfo; - surface: SessionSurface; - sessionAppBundleId: string | undefined; - appName: string | undefined; - existingSession: SessionState | undefined; -}): SessionState { - const { req, device, surface, sessionAppBundleId, appName, existingSession } = params; - const provisionalSession = buildNextOpenSession({ - existingSession, - sessionName: existingSession?.name ?? resolvePublicSessionName(req), - sessionScope: existingSession?.sessionScope ?? resolveOpenSessionScope(req), - device, - surface, - appBundleId: sessionAppBundleId, - appName, - }); - provisionalSession.lease = resolveSessionLeaseForRequest({ - req, - existingLease: existingSession?.lease, - }); - return provisionalSession; + requireOpenSessionAdmission(sessionStore, sessionName, ref); + return { type: 'session', ref: sessionStore.refresh(ref) }; } /** @@ -466,7 +452,7 @@ export async function openNewSessionWithDeviceClaim(params: { clearRuntimeHints?: RuntimeHintClearOperation; reconcileOrphanedDeviceClaim: DeviceClaimReconciler; selection?: DeviceSelectionResult; -}): Promise { +}): Promise { const { req, sessionName, @@ -482,8 +468,9 @@ export async function openNewSessionWithDeviceClaim(params: { reconcileOrphanedDeviceClaim, selection, } = params; + requireOpenSessionAdmission(sessionStore, sessionName, undefined); const conflict = findNewSessionDeviceConflict({ req, device, sessionStore }); - if (conflict) return conflict; + if (conflict) return { type: 'response', response: conflict }; const ownerClaim = await acquireDeviceClaimForOwner({ req, @@ -494,8 +481,11 @@ export async function openNewSessionWithDeviceClaim(params: { reconcileOrphanedDeviceClaim, }); if (ownerClaim.status === 'conflict') - return buildDeviceClaimConflictError(device, ownerClaim.conflict); - if (ownerClaim.status === 'refused') return ownerClaim.response; + return { + type: 'response', + response: buildDeviceClaimConflictError(device, ownerClaim.conflict), + }; + if (ownerClaim.status === 'refused') return { type: 'response', response: ownerClaim.response }; const deviceClaim = ownerClaim.status === 'acquired' ? ownerClaim.ownership : undefined; const tookOverDeviceClaim = ownerClaim.status === 'acquired' ? ownerClaim.tookOver : undefined; const effects: NewSessionOpenEffects = { mayHaveStarted: false }; @@ -507,6 +497,7 @@ export async function openNewSessionWithDeviceClaim(params: { sessionStore, }); try { + requireOpenSessionAdmission(sessionStore, sessionName, undefined); const details = await prepareOpenCommandDetails({ req, logPath, @@ -519,14 +510,14 @@ export async function openNewSessionWithDeviceClaim(params: { }); if (details.type === 'response') { await rollbackClaim(); - return details.response; + return { type: 'response', response: details.response }; } // Preparation can boot the device or warm caches, but it cannot establish session ownership. // Stamping here is what covers a boot preparation caused for this very open; from // `completeOpenCommand` on, a relaunch-close or a runtime-hint write may already have touched the // app, so a failure from that point cannot prove ownership was never established. const reclaimed = await renewOpenSessionClaim(device, deviceClaim); - if (reclaimed) return reclaimed; + if (reclaimed) return { type: 'response', response: reclaimed }; effects.mayHaveStarted = true; const requestedPositionals = req.positionals ?? []; // `open ` carries both positionals; only `--foreground`, which has none, gets its @@ -556,7 +547,7 @@ export async function openNewSessionWithDeviceClaim(params: { tookOverDeviceClaim, selection, }); - if (!response.ok) await rollbackClaim(); + if (!response.response.ok) await rollbackClaim(); return response; } catch (error) { await rollbackClaim(); diff --git a/src/daemon/session-lifecycle/internal/session-open-foreground.ts b/src/daemon/session-lifecycle/internal/session-open-foreground.ts index c222ce2bed..201b68c740 100644 --- a/src/daemon/session-lifecycle/internal/session-open-foreground.ts +++ b/src/daemon/session-lifecycle/internal/session-open-foreground.ts @@ -1,5 +1,6 @@ import { normalizeError, type NormalizedError } from '@agent-device/kernel/errors'; import { dispatchSnapshotViaRuntime } from '../../snapshot-runtime.ts'; +import type { SessionRef } from '../../session-state.ts'; import type { SessionStore } from '../../session-store.ts'; import type { DaemonRequest, DaemonResponse } from '../../daemon-request.ts'; import type { @@ -102,14 +103,14 @@ export async function resolveForegroundOpenRequest(params: { */ export async function composeOpenWithInitialSnapshot(params: { req: DaemonRequest; - sessionName: string; + ref: SessionRef; logPath: string; sessionStore: SessionStore; openResponse: DaemonResponse; inspectFacts: InspectDeviceRuntimeFacts; bindDevice: BindDeviceRuntime; }): Promise { - const { req, sessionName, logPath, sessionStore, openResponse } = params; + const { req, ref, logPath, sessionStore, openResponse } = params; if (!openResponse.ok || req.flags?.foreground !== true) return openResponse; try { @@ -123,7 +124,8 @@ export async function composeOpenWithInitialSnapshot(params: { // key the snapshot runtime reads as `interactiveOnly`. flags: { ...req.flags, snapshotInteractiveOnly: true }, }, - sessionName, + sessionName: ref.address, + sessionRef: ref, logPath, sessionStore, inspectFacts: params.inspectFacts, diff --git a/src/daemon/session-lifecycle/internal/session-open-state.ts b/src/daemon/session-lifecycle/internal/session-open-state.ts new file mode 100644 index 0000000000..828c6c564d --- /dev/null +++ b/src/daemon/session-lifecycle/internal/session-open-state.ts @@ -0,0 +1,71 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { SessionSurface } from '@agent-device/contracts/session'; +import type { DaemonRequest } from '../../daemon-request.ts'; +import type { SessionRef } from '../../session-state.ts'; +import type { SessionStore } from '../../session-store.ts'; +import type { DeviceClaimSessionOwnership } from '../../device/device-claims.ts'; +import { clearSessionSnapshot } from '../../session-snapshot.ts'; +import { resolveSessionLeaseForRequest } from '../../lease-lifecycle.ts'; +import { resolvePublicSessionName, resolveSessionScope } from '../../session-routing.ts'; + +export function requireOpenSessionAdmission( + store: SessionStore, + address: string, + ref: SessionRef | undefined, +): void { + if (ref) { + store.assertAdmissionOpen(ref.address); + store.requireCurrent(ref); + } else { + store.assertPublishable(address); + } +} + +export function publishOpenSession(params: { + req: DaemonRequest; + sessionStore: SessionStore; + sessionName: string; + existingRef?: SessionRef; + device: DeviceInfo; + surface: SessionSurface; + appBundleId?: string; + appName?: string; + deviceClaim?: DeviceClaimSessionOwnership; +}): SessionRef { + const { + req, + sessionStore: store, + sessionName, + existingRef, + device, + surface, + appBundleId, + appName, + deviceClaim, + } = params; + requireOpenSessionAdmission(store, sessionName, existingRef); + if (existingRef) { + const session = store.update(existingRef, { + device, + surface, + appBundleId, + appName, + }); + session.lease = resolveSessionLeaseForRequest({ req, existingLease: session.lease }); + if (deviceClaim) session.deviceClaim = deviceClaim; + clearSessionSnapshot(session); + return store.refresh(existingRef); + } + return store.publish(sessionName, { + name: resolvePublicSessionName(req), + sessionScope: req.internal?.resolvedSessionScope ?? resolveSessionScope(req), + device, + surface, + appBundleId, + appName, + createdAt: Date.now(), + actions: [], + lease: resolveSessionLeaseForRequest({ req }), + deviceClaim, + }); +} diff --git a/src/daemon/session-lifecycle/internal/session-open-surface.ts b/src/daemon/session-lifecycle/internal/session-open-surface.ts index 82befde932..daa220a92e 100644 --- a/src/daemon/session-lifecycle/internal/session-open-surface.ts +++ b/src/daemon/session-lifecycle/internal/session-open-surface.ts @@ -1,12 +1,12 @@ import type { LaunchConfirmation } from '@agent-device/contracts/application-lifecycle-runtime'; -import type { SessionScope, SessionSurface } from '@agent-device/contracts/session'; +import type { SessionSurface } from '@agent-device/contracts/session'; import { isIosFamily, isSerialAddressablePlatform, publicPlatformString, type DeviceInfo, } from '@agent-device/kernel/device'; -import type { SessionRuntimeHints, SessionState } from '../../session-state.ts'; +import type { SessionRuntimeHints } from '../../session-state.ts'; import { successText } from '@agent-device/kernel/success-text'; import type { StartupPerfSample } from './session-startup-metrics.ts'; import type { DeviceSelectionResult } from '@agent-device/device-selection/device-selection-resolver'; @@ -90,36 +90,3 @@ function selectionResponseData( const { device: _device, ...metadata } = selection; return { selection: metadata }; } - -export function buildNextOpenSession(params: { - existingSession?: SessionState; - sessionName: string; - sessionScope: SessionScope; - device: DeviceInfo; - surface: SessionSurface; - appBundleId?: string; - appName?: string; -}): SessionState { - const { existingSession, sessionName, sessionScope, device, surface, appBundleId, appName } = - params; - if (existingSession) { - return { - ...existingSession, - device, - surface, - appBundleId, - appName, - snapshot: undefined, - }; - } - return { - name: sessionName, - sessionScope, - device, - createdAt: Date.now(), - surface, - appBundleId, - appName, - actions: [], - }; -} diff --git a/src/daemon/session-lifecycle/internal/session-open.ts b/src/daemon/session-lifecycle/internal/session-open.ts index 098cb7f46d..8cc765fa3c 100644 --- a/src/daemon/session-lifecycle/internal/session-open.ts +++ b/src/daemon/session-lifecycle/internal/session-open.ts @@ -40,9 +40,11 @@ import { openNewSessionWithDeviceClaim, renewOpenSessionClaim, type OpenApplicationRuntime, + type SessionOpenResult, type RuntimeHintApplyOperation, type RuntimeHintClearOperation, } from './session-open-execution.ts'; +import { requireOpenSessionAdmission } from './session-open-state.ts'; import { errorResponse } from '@agent-device/kernel/contracts'; export type SessionOpenCommandInput = Readonly<{ @@ -149,23 +151,29 @@ async function resolveOpenRuntimePlanAdmission(params: { } // fallow-ignore-next-line complexity -async function handleOpenCommand(params: SessionOpenCommandInput): Promise { +async function handleOpenCommand(params: SessionOpenCommandInput): Promise { const { sessionName, logPath, sessionStore } = params; - const session = sessionStore.get(sessionName); + const existingRef = sessionStore.lookup(sessionName); const foregroundResolution = await resolveForegroundOpenRequest({ req: params.req, - hasExistingSession: Boolean(session), + hasExistingSession: Boolean(existingRef), }); - if (foregroundResolution.type === 'response') return foregroundResolution.response; + if (foregroundResolution.type === 'response') + return { type: 'response', response: foregroundResolution.response }; const req = foregroundResolution.type === 'resolved' ? foregroundResolution.req : params.req; - if (session) { + requireOpenSessionAdmission(sessionStore, sessionName, existingRef); + if (existingRef) { + const session = sessionStore.requireCurrent(existingRef); if (req.flags?.saveScript) { - return errorResponse( - 'INVALID_ARGS', - 'open --save-script can only arm a fresh session. Use the current session without --save-script, or close it and start a fresh session.', - ); + return { + type: 'response', + response: errorResponse( + 'INVALID_ARGS', + 'open --save-script can only arm a fresh session. Use the current session without --save-script, or close it and start a fresh session.', + ), + }; } const shouldRelaunch = req.flags?.relaunch === true; const requestedOpenTarget = req.positionals?.[0]; @@ -176,11 +184,14 @@ async function handleOpenCommand(params: SessionOpenCommandInput): Promise { - const openResponse = await handleOpenCommand(params); - if (!openResponse.ok || params.req.flags?.foreground !== true) return openResponse; + const result = await handleOpenCommand(params); + const openResponse = result.response; + if (result.type !== 'opened' || params.req.flags?.foreground !== true) return openResponse; return await composeOpenWithInitialSnapshot({ ...params, inspectFacts: requireRuntimeFacts(params.inspectFacts), bindDevice: requireRuntimeBinding(params.bindDevice), openResponse, + ref: result.ref, }); } diff --git a/src/daemon/session-observability/internal/__tests__/session-audio.test.ts b/src/daemon/session-observability/internal/__tests__/session-audio.test.ts index 37699f875d..7cf3bc0032 100644 --- a/src/daemon/session-observability/internal/__tests__/session-audio.test.ts +++ b/src/daemon/session-observability/internal/__tests__/session-audio.test.ts @@ -200,7 +200,7 @@ function audioParams( test('audio probe validates daemon duration bounds', async () => { const sessionStore = makeSessionStore('agent-device-session-audio-'); - sessionStore.set('web', makeSession('web', { device: WEB_DESKTOP_DEVICE })); + sessionStore.publish('web', makeSession('web', { device: WEB_DESKTOP_DEVICE })); const { params, bindDevice } = audioParams('web', sessionStore, WEB_DESKTOP_DEVICE, { positionals: ['probe', 'start', '99', '1000'], cells: { capture: false, query: true }, @@ -215,7 +215,7 @@ test('audio probe validates daemon duration bounds', async () => { test('audio probe validates daemon bucket bounds', async () => { const sessionStore = makeSessionStore('agent-device-session-audio-'); - sessionStore.set('web', makeSession('web', { device: WEB_DESKTOP_DEVICE })); + sessionStore.publish('web', makeSession('web', { device: WEB_DESKTOP_DEVICE })); const { params, bindDevice } = audioParams('web', sessionStore, WEB_DESKTOP_DEVICE, { positionals: ['probe', 'start', '1000', '99'], cells: { capture: false, query: true }, @@ -230,7 +230,7 @@ test('audio probe validates daemon bucket bounds', async () => { test('audio probe rejects timing positionals for status', async () => { const sessionStore = makeSessionStore('agent-device-session-audio-'); - sessionStore.set('web', makeSession('web', { device: WEB_DESKTOP_DEVICE })); + sessionStore.publish('web', makeSession('web', { device: WEB_DESKTOP_DEVICE })); const { params, bindDevice } = audioParams('web', sessionStore, WEB_DESKTOP_DEVICE, { positionals: ['probe', 'status', '1000'], cells: { capture: false, query: true }, @@ -242,7 +242,7 @@ test('audio probe rejects timing positionals for status', async () => { test('audio refuses with the owner-stated hint when no fact admits it', async () => { const sessionStore = makeSessionStore('agent-device-session-audio-'); - sessionStore.set('ios-device', makeIosSession('ios-device', { device: IOS_DEVICE })); + sessionStore.publish('ios-device', makeIosSession('ios-device', { device: IOS_DEVICE })); const { params, bindDevice } = audioParams('ios-device', sessionStore, IOS_DEVICE, { positionals: ['probe', 'status'], cells: { capture: false, query: false }, @@ -260,7 +260,7 @@ test('audio refuses with the owner-stated hint when no fact admits it', async () test('audio probe start binds once, adopts the durable handle, and answers from it', async () => { const sessionStore = makeSessionStore('agent-device-session-audio-'); const session = makeMacOsSession('macos'); - sessionStore.set('macos', session); + sessionStore.publish('macos', session); const runtime = fakeCaptureRuntime(session.device, runningStatus()); const { params, bindDevice } = audioParams('macos', sessionStore, session.device, { positionals: ['probe', 'start', '1000', '500'], @@ -289,7 +289,7 @@ test('audio probe start binds once, adopts the durable handle, and answers from test('audio probe starts host helper for iOS simulator audio', async () => { const sessionStore = makeSessionStore('agent-device-session-audio-'); const session = makeIosSession('ios-sim'); - sessionStore.set('ios-sim', session); + sessionStore.publish('ios-sim', session); const runtime = fakeCaptureRuntime(session.device, runningStatus()); const { params } = audioParams('ios-sim', sessionStore, session.device, { positionals: ['probe', 'start', '1000', '500'], @@ -307,7 +307,7 @@ test('audio probe starts host helper for iOS simulator audio', async () => { test(ANDROID_AUDIO_CONTRACT_EVIDENCE.testName, async () => { const sessionStore = makeSessionStore('agent-device-session-audio-'); const session = makeAndroidSession('android'); - sessionStore.set('android', session); + sessionStore.publish('android', session); const runtime = fakeCaptureRuntime(session.device, runningStatus({ peakDbfs: [-13] })); const { params } = audioParams('android', sessionStore, session.device, { positionals: ['probe', 'start', '1000', '500'], @@ -325,7 +325,7 @@ test(ANDROID_AUDIO_CONTRACT_EVIDENCE.testName, async () => { test('audio probe stop finishes the durable resource and clears the slot', async () => { const sessionStore = makeSessionStore('agent-device-session-audio-'); const session = makeMacOsSession('macos'); - sessionStore.set('macos', session); + sessionStore.publish('macos', session); const runtime = fakeCaptureRuntime(session.device, runningStatus()); const start = audioParams('macos', sessionStore, session.device, { positionals: ['probe', 'start', '1000', '500'], @@ -355,7 +355,7 @@ test('audio probe stop finishes the durable resource and clears the slot', async test('audio probe status without an active probe reports not-started', async () => { const sessionStore = makeSessionStore('agent-device-session-audio-'); const session = makeMacOsSession('macos'); - sessionStore.set('macos', session); + sessionStore.publish('macos', session); const { params } = audioParams('macos', sessionStore, session.device, { positionals: ['probe', 'status'], cells: { capture: true, query: false }, @@ -370,7 +370,7 @@ test('audio probe status without an active probe reports not-started', async () test('audio probe forwards daemon millisecond timing to the web query operation', async () => { const sessionStore = makeSessionStore('agent-device-session-audio-'); - sessionStore.set('web', makeSession('web', { device: WEB_DESKTOP_DEVICE })); + sessionStore.publish('web', makeSession('web', { device: WEB_DESKTOP_DEVICE })); const audioProbeQuery = vi.fn( async (_input: { action: string; durationMs: number; bucketMs: number }) => runningStatus({ source: 'media-elements', backend: 'agent-browser' }), diff --git a/src/daemon/session-observability/internal/__tests__/session-logs.test.ts b/src/daemon/session-observability/internal/__tests__/session-logs.test.ts index fb40412fe7..e3f426bac6 100644 --- a/src/daemon/session-observability/internal/__tests__/session-logs.test.ts +++ b/src/daemon/session-observability/internal/__tests__/session-logs.test.ts @@ -94,8 +94,7 @@ test('logs path binds only inspect and preserves public status projection', asyn test('logs doctor binds only doctor and merges live-state notes', async () => { const { sessionStore, sessionName } = openSession(); const session = sessionStore.get(sessionName)!; - sessionStore.set(sessionName, { - ...session, + sessionStore.update(sessionStore.lookup(sessionName)!, { appLog: makeTestAppLogResource(session, { backend: 'ios-simulator', state: 'ended', @@ -176,8 +175,7 @@ test.each([ '$action proves the app session before binding start operations', async ({ action, flags = {}, message }) => { const { sessionStore, sessionName } = openSession(); - const session = sessionStore.get(sessionName)!; - sessionStore.set(sessionName, { ...session, appBundleId: undefined }); + sessionStore.update(sessionStore.lookup(sessionName)!, { appBundleId: undefined }); const response = await runLogs( sessionStore, @@ -269,7 +267,7 @@ test('rejected pending cleanup retains cleanup-pending record and blocks replace test('post-transfer SessionStore failure disposes the transferred handle and preserves primary error', async () => { const { sessionStore, sessionName } = openSession(); const primary = new Error('store adoption failed'); - vi.spyOn(sessionStore, 'set').mockImplementationOnce(() => { + vi.spyOn(sessionStore, 'update').mockImplementationOnce(() => { throw primary; }); const response = await runLogs(sessionStore, sessionName, ['start'], {}, runtime.bindDevice); @@ -281,7 +279,7 @@ test('post-transfer SessionStore failure disposes the transferred handle and pre function openSession() { const sessionStore = makeSessionStore(); const sessionName = 'logs-session'; - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, DEVICE), appBundleId: 'com.example.app', }); diff --git a/src/daemon/session-observability/internal/__tests__/session-network-runtime-parity.test.ts b/src/daemon/session-observability/internal/__tests__/session-network-runtime-parity.test.ts index 7f07ef5112..78f56520df 100644 --- a/src/daemon/session-observability/internal/__tests__/session-network-runtime-parity.test.ts +++ b/src/daemon/session-observability/internal/__tests__/session-network-runtime-parity.test.ts @@ -24,7 +24,7 @@ test.each(NETWORK_RUNTIME_PROJECTION_PARITY)( kind: 'emulator', booted: true, }); - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...session, appBundleId: 'com.example.app', appLog: makeTestAppLogResource(session, { @@ -104,7 +104,7 @@ test('network admission remains fail-closed before parsing invalid input', async name: 'Harmony device', kind: 'device', }); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const runtime = createNetworkRuntime(session.device, async () => emptyAppLogResult('harmonyos'), { available: false, reason: 'unsupported-platform-leaf', @@ -147,7 +147,7 @@ test('an explicit web provider preserves empty-success projection', async () => kind: 'device', target: 'desktop', }); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const runtime = createNetworkRuntime( session.device, async () => ({ source: 'provider', backend: 'agent-browser', entries: [], notes: [] }), @@ -181,7 +181,7 @@ test.each(['browserstack', 'aws-device-farm', 'limrun'] as const)( name: `${provider} device`, kind: 'device', }); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const runtime = createNetworkRuntime( session.device, async () => emptyAppLogResult('android'), diff --git a/src/daemon/session-observability/internal/__tests__/session-network.test.ts b/src/daemon/session-observability/internal/__tests__/session-network.test.ts index 2c54450d96..1bb7d3a0b3 100644 --- a/src/daemon/session-observability/internal/__tests__/session-network.test.ts +++ b/src/daemon/session-observability/internal/__tests__/session-network.test.ts @@ -39,7 +39,7 @@ test('network validates the legacy limit after runtime-fact admission', async () name: 'Pixel', kind: 'emulator', }); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const runtime = createNetworkRuntime(session.device, async (input) => emptyAppLogResult('android', input), ); diff --git a/src/daemon/session-observability/internal/__tests__/session-observability.test.ts b/src/daemon/session-observability/internal/__tests__/session-observability.test.ts index a4164676e1..a3df8a5f50 100644 --- a/src/daemon/session-observability/internal/__tests__/session-observability.test.ts +++ b/src/daemon/session-observability/internal/__tests__/session-observability.test.ts @@ -104,6 +104,9 @@ test.each(REMOVED_AGGREGATE_PERF_POSITIONALS.map((positionals) => [positionals] function makeAndroidStore() { const sessionStore = makeSessionStore('agent-device-session-observability-'); - sessionStore.set('android', makeAndroidSession('android', { appBundleId: 'com.example.app' })); + sessionStore.publish( + 'android', + makeAndroidSession('android', { appBundleId: 'com.example.app' }), + ); return sessionStore; } diff --git a/src/daemon/session-observability/internal/__tests__/session-perf-runtime.test.ts b/src/daemon/session-observability/internal/__tests__/session-perf-runtime.test.ts index 6547e94231..365253e483 100644 --- a/src/daemon/session-observability/internal/__tests__/session-perf-runtime.test.ts +++ b/src/daemon/session-observability/internal/__tests__/session-perf-runtime.test.ts @@ -201,9 +201,48 @@ test('perf native capture is adopted durably and stop uses the live handle witho ); }); +test.each(['shutdown', 'retire'] as const)( + 'perf refuses native startup after %s during binding', + async (change) => { + const sessionStore = makeStore(); + const ref = sessionStore.lookup('android')!; + const start = vi.fn(); + const runtime = createPerfRuntime({ perfNativeCaptureStart: start }); + const bindDevice: BindDeviceRuntime = async (device, use) => { + const bound = await runtime.bindDevice(device, use); + if (change === 'shutdown') sessionStore.closeAdmission(); + else sessionStore.retire(ref); + return bound; + }; + const response = await handleSessionObservabilityCommands({ + req: { + token: 't', + session: 'android', + command: 'perf', + positionals: ['trace', 'start', 'xctrace'], + }, + sessionName: 'android', + sessionStore, + inspectFacts: runtime.inspectFacts, + bindDevice, + perfCaptureAdmissionLedger: createPerfCaptureAdmissionLedger(), + }); + assert.equal(response?.ok, false); + if (response && !response.ok) + assert.equal( + response.error.details?.reason, + change === 'shutdown' ? 'daemon_shutting_down' : 'session_lifetime_ended', + ); + assert.equal(start.mock.calls.length, 0); + }, +); + function makeStore() { const sessionStore = makeSessionStore('agent-device-perf-runtime-'); - sessionStore.set('android', makeAndroidSession('android', { appBundleId: 'com.example.app' })); + sessionStore.publish( + 'android', + makeAndroidSession('android', { appBundleId: 'com.example.app' }), + ); return sessionStore; } diff --git a/src/daemon/session-observability/internal/session-audio.ts b/src/daemon/session-observability/internal/session-audio.ts index a7e60b98c7..9a947fa1ce 100644 --- a/src/daemon/session-observability/internal/session-audio.ts +++ b/src/daemon/session-observability/internal/session-audio.ts @@ -20,7 +20,8 @@ import type { } from '../../request-runtime-binding.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 } from '../../session-state.ts'; +import { bindSessionAudioProbe } from '../../session-capture-binding.ts'; import { type DaemonFailureResponse, errorResponse } from '@agent-device/kernel/contracts'; type AudioParams = { @@ -44,7 +45,8 @@ export async function handleAudioCommand(params: AudioParams): Promise { const sessionResult = resolveAudioSession(params); if (!sessionResult.ok) return sessionResult; - const session = sessionResult.session; + const ref = sessionResult.ref; + const session = params.sessionStore.requireCurrent(ref); const request = parseAudioProbeRequest(params.req.positionals); // Facts, not a capability bucket, decide which of the two owner paths this device has — // side-effect-free per ADR 0019 §9; the one bind below uses the plan's own use. @@ -67,20 +69,20 @@ async function handleAudioCommandUnsafe(params: AudioParams): Promise { + let session = params.sessionStore.requireCurrent(ref); + const binding = bindSessionAudioProbe(params.sessionStore, ref); // Start restarts an already-running probe (legacy parity), completing it through the durable // coordinator so the previous envelope terminalizes before a new fence is minted. Nobody reads // that completion, so the previous probe is being handed back rather than captured. if (session.audioProbe) { await finishLiveAudioProbe({ intent: 'disposal', - session, - sessionName: params.sessionName, - sessionStore: params.sessionStore, + binding, }); - const refreshed = params.sessionStore.get(params.sessionName); - if (!refreshed) return errorResponse('SESSION_NOT_FOUND', 'audio requires an active session'); - session = refreshed; + session = params.sessionStore.requireCurrent(ref); } const runtime = await params.bindDevice(session.device, use); const resourcePath = audioProbeDurableResource.store.resolvePath( @@ -130,6 +130,7 @@ async function startAudioProbe( params.sessionStore.ensureSessionDir(params.sessionName), 'audio-probe.json', ); + binding.assertAdoptable(); const started = await runtime.operations.audioProbeStart({ sessionId: params.sessionName, statusPath, @@ -139,25 +140,21 @@ async function startAudioProbe( }); await adoptStartedAudioProbe({ admissionLedger: params.audioProbeAdmissionLedger, - session, - sessionName: params.sessionName, - sessionStore: params.sessionStore, + binding, device: session.device, owner: runtime.owner, fence, ...started, throwIfCanceled: params.throwIfCanceled, }); - const adopted = params.sessionStore.get(params.sessionName)?.audioProbe; + const adopted = binding.read(); if (!adopted) throw new TypeError('Audio probe adoption did not publish a live handle'); return { ok: true, data: await adopted.handle.status() }; } -async function audioProbeStatus( - params: AudioParams, - session: SessionState, -): Promise { - const probe = session.audioProbe; +async function audioProbeStatus(params: AudioParams, ref: SessionRef): Promise { + const binding = bindSessionAudioProbe(params.sessionStore, ref); + const probe = binding.read(); if (!probe) return { ok: true, data: inactiveAudioProbeResult() }; const data = await probe.handle.status(); if (data.state === 'stopped') { @@ -166,21 +163,18 @@ async function audioProbeStatus( // status, never for an export no later stop could produce from a dead sampler. await finishLiveAudioProbe({ intent: 'disposal', - session, - sessionName: params.sessionName, - sessionStore: params.sessionStore, + binding, }); } return { ok: true, data }; } -async function stopAudioProbe(params: AudioParams, session: SessionState): Promise { - if (!session.audioProbe) return { ok: true, data: inactiveAudioProbeResult() }; +async function stopAudioProbe(params: AudioParams, ref: SessionRef): Promise { + const binding = bindSessionAudioProbe(params.sessionStore, ref); + if (!binding.read()) return { ok: true, data: inactiveAudioProbeResult() }; const completion = await finishLiveAudioProbe({ intent: 'capture', - session, - sessionName: params.sessionName, - sessionStore: params.sessionStore, + binding, }); return { ok: true, data: completion }; } diff --git a/src/daemon/session-observability/internal/session-observability.ts b/src/daemon/session-observability/internal/session-observability.ts index 2171842c39..8b4302a297 100644 --- a/src/daemon/session-observability/internal/session-observability.ts +++ b/src/daemon/session-observability/internal/session-observability.ts @@ -18,6 +18,7 @@ import { type PerfCaptureAdmissionLedger } from '@agent-device/capture-kit/perf- import { appLogResourceStore } from '../../app-log-resource-store.ts'; import { adoptStartedSessionAppLog, + bindSessionAppLog, clearSessionAppLogFailure, finishSessionAppLog, inspectSessionAppLog, @@ -31,7 +32,7 @@ import type { } from '../../request-runtime-binding.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'; import { handleAudioCommand } from './session-audio.ts'; import { handlePerfRuntimeCommand } from './session-perf-runtime.ts'; import { handleNetworkCommand } from './session-network.ts'; @@ -51,6 +52,7 @@ export type SessionObservabilityCommandInput = { type ObservabilityInput = SessionObservabilityCommandInput; type LogsHandlerParams = Omit & { session: SessionState; + ref: SessionRef; bindDevice: BindDeviceRuntime; appLogAdmissionLedger: AppLogAdmissionLedger; }; @@ -143,12 +145,13 @@ async function handleEventsCommand(params: ObservabilityInput): Promise { const { req, sessionName, sessionStore } = params; - const session = sessionStore.get(sessionName); - if (!session) { + const ref = sessionStore.lookup(sessionName); + if (!ref) { return errorResponse('SESSION_NOT_FOUND', 'logs requires an active session'); } try { - const logsParams = requireLogsHandlerParams({ ...params, session }); + const session = sessionStore.requireCurrent(ref); + const logsParams = requireLogsHandlerParams({ ...params, session, ref }); const admission = await logsParams.bindDevice(session.device, appLogAdmissionUse); const inspectFact = admission.facts.appLogInspect; if (!inspectFact.available) { @@ -305,7 +308,7 @@ function handleLogsClear(params: LogsHandlerParams): DaemonResponse { } const logPath = sessionStore.resolveAppLogPath(sessionName); const cleared = clearAppLogFiles(logPath); - clearSessionAppLogFailure({ session, sessionName, sessionStore }); + clearSessionAppLogFailure({ ref: params.ref, sessionStore }); return { ok: true, data: cleared }; } @@ -321,10 +324,8 @@ async function handleLogsClearRestart( // an open record left here would refuse the start this path exists to serve. await finishSessionAppLog({ intent: 'disposal', - session, - sessionName, + ref: params.ref, sessionStore, - resourcePath: appLogResourceStore.resolvePath(sessionStore.resolveSessionDir(sessionName)), }); } const logPath = sessionStore.resolveAppLogPath(sessionName); @@ -354,10 +355,8 @@ async function handleLogsStop(params: LogsHandlerParams): Promise { - const session = params.sessionStore.get(params.sessionName); - if (!session) { + const ref = params.sessionStore.lookup(params.sessionName); + if (!ref) { return errorResponse('SESSION_NOT_FOUND', 'perf requires an active session. Run open first.'); } + const session = params.sessionStore.requireCurrent(ref); + const bound = { ...params, ref }; try { if (isRemovedAggregatePerfToken(params.req.positionals?.[0])) { throw new AppError('INVALID_ARGS', PERF_AGGREGATE_REMOVED_ERROR_MESSAGE); @@ -71,8 +75,8 @@ export async function handlePerfRuntimeCommand( const plan = resolvePerfRuntimePlan(request); if (plan.kind === 'capture-stop') { return recordSuccessfulPerfResponse( - params, - await stopPerfCapture(params, session, plan.request), + bound, + await stopPerfCapture(bound, session, plan.request), ); } const admitted = await admitRuntimePlan({ @@ -87,8 +91,8 @@ export async function handlePerfRuntimeCommand( ); } return recordSuccessfulPerfResponse( - params, - await executeAdmittedPerfPlan(params, session, admitted), + bound, + await executeAdmittedPerfPlan(bound, session, admitted), ); } catch (error) { return { ok: false, error: normalizeError(error) }; @@ -96,14 +100,13 @@ export async function handlePerfRuntimeCommand( } function recordSuccessfulPerfResponse( - params: PerfRuntimeHandlerParams, + params: PerfRuntimeHandlerParams & { ref: SessionRef }, response: DaemonResponse, ): DaemonResponse { if (!response.ok) return response; - const session = params.sessionStore.get(params.sessionName); recordSessionAction( params.sessionStore, - session, + params.ref, params.req, 'perf', isDataRecord(response.data) ? { ...response.data } : {}, @@ -115,7 +118,7 @@ function recordSuccessfulPerfResponse( // the admission/runtime join this handler is meant to keep singular. // fallow-ignore-next-line complexity async function executeAdmittedPerfPlan( - params: PerfRuntimeHandlerParams, + params: PerfRuntimeHandlerParams & { ref: SessionRef }, session: SessionState, admission: AdmittedRuntimePlan>, ): Promise { @@ -138,7 +141,7 @@ async function executeAdmittedPerfPlan( appId: session.appBundleId, kind: plan.request.kind, outPath: plan.request.outPath - ? SessionStore.expandHome(plan.request.outPath, params.req.meta?.cwd) + ? expandSessionPath(plan.request.outPath, params.req.meta?.cwd) : undefined, artifactsDir: path.join( params.sessionStore.ensureSessionDir(params.sessionName), @@ -171,7 +174,7 @@ async function executeAdmittedPerfPlan( const data = await runtime.operations.perfProfileReport({ appId: session.appBundleId, kind: plan.request.kind, - tracePath: SessionStore.expandHome(tracePath, params.req.meta?.cwd), + tracePath: expandSessionPath(tracePath, params.req.meta?.cwd), outPath, template: plan.request.template ?? (last?.kind === 'xctrace' ? last.template : undefined), profile: last, @@ -182,7 +185,7 @@ async function executeAdmittedPerfPlan( } async function startPerfCapture( - params: PerfRuntimeHandlerParams, + params: PerfRuntimeHandlerParams & { ref: SessionRef }, session: SessionState, runtime: Readonly<{ owner: Parameters[0]['owner']; @@ -213,6 +216,8 @@ async function startPerfCapture( resourcePath, device: session.device, }); + const binding = bindSessionPerfCapture(params.sessionStore, params.ref); + binding.assertAdoptable(); const started = await runtime.operations.perfNativeCaptureStart({ sessionId: params.sessionName, appId: session.appBundleId, @@ -224,9 +229,7 @@ async function startPerfCapture( }); await adoptStartedPerfCapture({ admissionLedger: requirePerfCaptureAdmissionLedger(params), - session, - sessionName: params.sessionName, - sessionStore: params.sessionStore, + binding, device: session.device, owner: runtime.owner, fence, @@ -247,7 +250,7 @@ function requirePerfCaptureAdmissionLedger( } async function stopPerfCapture( - params: PerfRuntimeHandlerParams, + params: PerfRuntimeHandlerParams & { ref: SessionRef }, session: SessionState, request: Extract, ): Promise { @@ -257,18 +260,15 @@ async function stopPerfCapture( const mismatchMessage = perfCaptureStopMismatch(snapshot, request); if (mismatchMessage) return errorResponse('INVALID_ARGS', mismatchMessage); if (request.outPath) { - capture.handle.setOutputPath(SessionStore.expandHome(request.outPath, params.req.meta?.cwd)); + capture.handle.setOutputPath(expandSessionPath(request.outPath, params.req.meta?.cwd)); } const completion = await finishLivePerfCapture({ intent: 'capture', - session, - sessionName: params.sessionName, - sessionStore: params.sessionStore, + binding: bindSessionPerfCapture(params.sessionStore, params.ref), }); if (request.area === 'cpu') { const profile = readProfileHandoff(completion); - const refreshed = params.sessionStore.get(params.sessionName) ?? session; - params.sessionStore.set(params.sessionName, { ...refreshed, lastPerfProfile: profile }); + params.sessionStore.update(params.ref, { lastPerfProfile: profile }); } return { ok: true, data: completion }; } @@ -395,7 +395,7 @@ function resolveNativeOutPath( requestedPath: string | undefined, fallbackFileName: string, ): string { - if (requestedPath) return SessionStore.expandHome(requestedPath, params.req.meta?.cwd); + if (requestedPath) return expandSessionPath(requestedPath, params.req.meta?.cwd); return path.join( params.sessionStore.ensureSessionDir(params.sessionName), `${timestampToken()}-${fallbackFileName}`, diff --git a/src/daemon/session-recovery-hints.test.ts b/src/daemon/session-recovery-hints.test.ts index c1f0930fa4..15aceaa94d 100644 --- a/src/daemon/session-recovery-hints.test.ts +++ b/src/daemon/session-recovery-hints.test.ts @@ -1,3 +1,4 @@ +import { makeStoredSessionRef } from '../__tests__/test-utils/store-factory.ts'; import { test, expect } from 'vitest'; import { buildSessionRecoveryHint } from './session-recovery-hints.ts'; import type { SessionRef, SessionState } from './session-state.ts'; @@ -12,9 +13,8 @@ import { IOS_SIMULATOR } from '../__tests__/test-utils/device-fixtures.ts'; const SCOPED_ADDRESS = 'cwd:8bea844ab16aa9b3:default'; function scopedRef(overrides: Partial = {}): SessionRef { - return { - address: SCOPED_ADDRESS, - session: { + return makeStoredSessionRef( + { name: 'default', sessionScope: { kind: 'cwd', id: '8bea844ab16aa9b3' }, device: IOS_SIMULATOR, @@ -22,7 +22,8 @@ function scopedRef(overrides: Partial = {}): SessionRef { actions: [], ...overrides, }, - }; + SCOPED_ADDRESS, + ); } test('device-in-use recovery names the address --session accepts, not the public name', () => { @@ -55,10 +56,7 @@ test('a recording session recovery uses the address for both close and record st test('an explicitly named session addresses itself unchanged', () => { const hint = buildSessionRecoveryHint( - { - address: 'checkout', - session: { ...scopedRef().session, name: 'checkout', sessionScope: undefined }, - }, + makeStoredSessionRef({ ...scopedRef().session, name: 'checkout', sessionScope: undefined }), 'device-in-use', ); @@ -81,10 +79,7 @@ test('a device or target conflict does not offer a platform session it cannot an test('selector-conflict recovery offers no platform session to a hand-named session', () => { const hint = buildSessionRecoveryHint( - { - address: 'checkout', - session: { ...scopedRef().session, name: 'checkout', sessionScope: undefined }, - }, + makeStoredSessionRef({ ...scopedRef().session, name: 'checkout', sessionScope: undefined }), 'selector-conflict', ); diff --git a/src/daemon/session-runtime.ts b/src/daemon/session-runtime.ts index 2cc69922f2..e218c35e27 100644 --- a/src/daemon/session-runtime.ts +++ b/src/daemon/session-runtime.ts @@ -199,7 +199,7 @@ export function setSessionRuntimeHintsForOpen( ): SessionRuntimeHints | undefined { if (!runtime) return undefined; if (countConfiguredRuntimeHints(runtime) === 0) { - sessionStore.clearRuntimeHints(sessionName); + sessionStore.setRuntimeHints(sessionName, undefined); return undefined; } sessionStore.setRuntimeHints(sessionName, runtime); diff --git a/src/daemon/session-snapshot.ts b/src/daemon/session-snapshot.ts index 0f0bb71822..96efd49f5b 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,24 @@ 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( +export function clearSessionSnapshot(session: SessionState): void { + session.snapshot = undefined; +} + +/** 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 +125,13 @@ 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.resolveCurrent(ref); + if (!session) return undefined; markSessionPartialRefsIssued(session, collectSettleIssuedRefBodies(settle)); return session.snapshotGeneration; } diff --git a/src/daemon/session-state.ts b/src/daemon/session-state.ts index 8e3350ff04..bf5ee40bb8 100644 --- a/src/daemon/session-state.ts +++ b/src/daemon/session-state.ts @@ -108,10 +108,11 @@ export type PostGestureStabilization = { * target takes this pair rather than a bare record, so it cannot be handed a session whose address * was never resolved. */ -export type SessionRef = { +export type SessionRef = Readonly<{ address: string; session: SessionState; -}; + lifetime: object; +}>; export type SessionState = { name: string; diff --git a/src/daemon/session-store.ts b/src/daemon/session-store.ts index 1598dd9414..996ec78211 100644 --- a/src/daemon/session-store.ts +++ b/src/daemon/session-store.ts @@ -4,13 +4,15 @@ import { AppError } from '@agent-device/kernel/errors'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import type { SessionRef, SessionRuntimeHints, SessionState } from './session-state.ts'; import { recordActionEntry, type RecordActionEntry } from './session-action-recorder.ts'; +import { isSafeSessionSegment, safeSessionName } from '@agent-device/host-kit/session-paths'; import { - expandSessionPath, - isSafeSessionSegment, - safeSessionName, -} from '@agent-device/host-kit/session-paths'; + resolveSessionDir, + resolveSessionAppLogPath, + resolveSessionAppLogPidPath, +} from './session-artifact-paths.ts'; import { readRepairTombstoneFile, + clearRepairTombstoneFile, resolveRepairTombstonePath, type RepairSessionTombstone, } from '../session-repair-tombstone.ts'; @@ -42,9 +44,12 @@ import { } from '@agent-device/session-journal/session-event-log'; const REPAIR_TOMBSTONE_TTL_MS = 60 * 60_000; +type SessionEntry = { current: SessionState }; +type SessionPatch = Partial | ((current: SessionState) => Partial); export class SessionStore { - private readonly sessions = new Map(); + private readonly sessions = new Map(); + private acceptingSessions = true; private readonly runtimeHints = new Map(); private readonly sessionsDir: string; private readonly scriptWriter: SessionScriptWriter; @@ -55,43 +60,92 @@ export class SessionStore { } /** - * Returns the LIVE record, not a copy: mutating a field on the result is a durable write - * to store state whether or not {@link SessionStore.set} is called afterwards. Which - * modules may do that is declared in `SESSION_STATE_FIELD_OWNERS` - * (`scripts/layering/session-state.ts`) and enforced by the layering gate's R7, because - * nothing here can check the invariant a given field carries. + * Returns the live record. Field owners may mutate it through their transitions; + * record rebuilds use a lifetime-checked update. R7 enforces field ownership. */ get(name: string): SessionState | undefined { - return this.sessions.get(name); + return this.sessions.get(name)?.current; } - /** - * Insert or replace a session. Calling this with a record obtained from - * {@link SessionStore.get} is a no-op — the reference is already stored — so the call - * documents intent rather than committing anything; a genuinely new record needs it. - */ - set(name: string, session: SessionState): void { - // A key with no record is a NEW occupant, and the previous occupant's idle-expiry marker must - // stop explaining this key's absences from now on. Clearing it here rather than at `open` covers - // every way a record arrives — `open`'s provisional record, a record-only `record` session — and - // cannot be forgotten by a future insertion path. A replacing `open` on a live session takes the - // other branch and keeps whatever marker that session will earn for itself. - const occupying = this.sessions.has(name); - this.sessions.set(name, session); - if (!occupying) this.clearIdleExpiryTombstone(name); + closeAdmission(): void { + this.acceptingSessions = false; + } + + assertAdmissionOpen(address: string): void { + if (!this.acceptingSessions) { + throw new AppError('COMMAND_FAILED', 'Daemon is shutting down', { + reason: 'daemon_shutting_down', + session: address, + }); + } + } + + assertPublishable(address: string): void { + this.assertAdmissionOpen(address); + if (this.sessions.has(address)) { + throw new AppError('COMMAND_FAILED', 'Session address is already occupied', { + reason: 'session_address_occupied', + session: address, + }); + } + } + + publish(address: string, session: SessionState): SessionRef { + this.assertPublishable(address); + const entry = { current: session }; + this.sessions.set(address, entry); + this.clearIdleExpiryTombstone(address); + return this.captureRef(address, entry); + } + + resolveCurrent(ref: SessionRef): SessionState | undefined { + const entry = this.sessions.get(ref.address); + return entry === ref.lifetime ? entry.current : undefined; + } + + refresh(ref: SessionRef): SessionRef { + const entry = this.sessions.get(ref.address); + return entry === ref.lifetime ? this.captureRef(ref.address, entry) : ref; + } + + requireCurrent(ref: SessionRef): SessionState { + const session = this.resolveCurrent(ref); + if (!session) { + throw new AppError('COMMAND_FAILED', 'Session lifetime has ended', { + reason: 'session_lifetime_ended', + session: ref.address, + hint: 'Open a new session before retrying the command.', + }); + } + return session; + } + + /** Patch callbacks are synchronous and must not call back into the store. */ + update(ref: SessionRef, patch: SessionPatch): SessionState { + const current = this.requireCurrent(ref); + const entry = this.sessions.get(ref.address)!; + const changes = typeof patch === 'function' ? patch(current) : patch; + const next = { ...current, ...changes }; + entry.current = next; + return next; + } + + retire(ref: SessionRef): boolean { + if (!this.resolveCurrent(ref)) return false; + this.runtimeHints.delete(ref.address); + return this.sessions.delete(ref.address); } - delete(name: string): boolean { - this.runtimeHints.delete(name); - return this.sessions.delete(name); + private captureRef(address: string, entry: SessionEntry): SessionRef { + return Object.freeze({ address, session: entry.current, lifetime: entry }); } - values(): IterableIterator { - return this.sessions.values(); + *values(): IterableIterator { + for (const entry of this.sessions.values()) yield entry.current; } toArray(): SessionState[] { - return Array.from(this.sessions.values()); + return Array.from(this.values()); } /** @@ -100,40 +154,41 @@ export class SessionStore { * falls back to `SessionState.name` (#2031/#1394). */ lookup(address: string): SessionRef | undefined { - const session = this.sessions.get(address); - return session ? { address, session } : undefined; + const entry = this.sessions.get(address); + return entry ? this.captureRef(address, entry) : undefined; } /** The session currently bound to `deviceId`, with its address, or `undefined` if none is. */ findByDevice(deviceId: string): SessionRef | undefined { - for (const [address, session] of this.sessions) { - if (session.device.id === deviceId) return { address, session }; + for (const [address, entry] of this.sessions) { + if (entry.current.device.id === deviceId) return this.captureRef(address, entry); } return undefined; } /** Every live session with its address, for surfaces that must report what `--session` accepts. */ listRefs(): SessionRef[] { - return Array.from(this.sessions, ([address, session]) => ({ address, session })); + return Array.from(this.sessions, ([address, entry]) => this.captureRef(address, entry)); } getRuntimeHints(name: string): SessionRuntimeHints | undefined { return this.runtimeHints.get(name); } - setRuntimeHints(name: string, hints: SessionRuntimeHints): void { - this.runtimeHints.set(name, hints); + setRuntimeHints(address: string, hints: SessionRuntimeHints | undefined): void { + if (hints) this.runtimeHints.set(address, hints); + else this.runtimeHints.delete(address); } - clearRuntimeHints(name: string): boolean { - return this.runtimeHints.delete(name); + clearRuntimeHints(ref: SessionRef): boolean { + this.requireCurrent(ref); + return this.runtimeHints.delete(ref.address); } - recordAction(session: SessionState, entry: RecordActionEntry): void { - const action = recordActionEntry(session, entry); + recordAction(ref: SessionRef, entry: RecordActionEntry): void { + const action = recordActionEntry(this.requireCurrent(ref), entry); if (action) { - const sessionName = this.resolveStoredSessionName(session); - appendActionEvent(this.resolveEventLogPath(sessionName), sessionName, action); + appendActionEvent(this.resolveEventLogPath(ref.address), ref.address, action); } } @@ -154,10 +209,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({ @@ -196,22 +249,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 { - this.recordRepairFinalizeCloseIfCommitting(session); + finalizeRepairTeardown(ref: SessionRef): void { + const session = this.resolveCurrent(ref); + if (!session) return; + this.recordRepairFinalizeCloseIfCommitting(ref); // #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); } } } @@ -224,10 +279,11 @@ export class SessionStore { * (incomplete) transaction's write is a no-op regardless, so there is * nothing to make self-contained. */ - private recordRepairFinalizeCloseIfCommitting(session: SessionState): void { + private recordRepairFinalizeCloseIfCommitting(ref: SessionRef): void { + const session = this.requireCurrent(ref); const state = session.scriptPublication ?? NO_SCRIPT_PUBLICATION; if (!isRepairCommittable(state)) return; - this.recordAction(session, { + this.recordAction(ref, { command: 'close', positionals: [], flags: {}, @@ -247,15 +303,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) @@ -263,13 +321,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), }, }); @@ -278,14 +336,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); } /** @@ -298,7 +354,7 @@ export class SessionStore { * never built, and its own `createdAt` already starts that session's deadline clock. */ noteSessionActivity(address: string, atMs: number = Date.now()): void { - const session = this.sessions.get(address); + const session = this.get(address); if (!session) return; session.lastActivityAtMs = atMs; } @@ -380,20 +436,8 @@ export class SessionStore { return path.join(this.sessionsDir, `${safeName}-${timestamp}.trace.log`); } - /** - * The one place a session name becomes a directory, so the invariant that every - * session dir lies beneath `sessionsDir` is enforced here rather than by each - * caller: `.` and `..` survive `safeSessionName` and would resolve to the - * sessions dir itself or the daemon state dir above it. - */ resolveSessionDir(sessionName: string): string { - if (!isSafeSessionSegment(sessionName)) { - throw new AppError( - 'INVALID_ARGS', - `Invalid session name ${JSON.stringify(sessionName)}: a session name cannot be empty, ".", or "..".`, - ); - } - return path.join(this.sessionsDir, safeSessionName(sessionName)); + return resolveSessionDir(this.sessionsDir, sessionName); } // Daemon state dir (parent of the `sessions/` dir), matching daemonPaths.baseDir. Called via @@ -411,29 +455,14 @@ export class SessionStore { /** Path to session-scoped app log file. Agent can grep this for token-efficient debugging. */ resolveAppLogPath(sessionName: string): string { - return path.join(this.resolveSessionDir(sessionName), 'app.log'); + return resolveSessionAppLogPath(this.sessionsDir, sessionName); } resolveAppLogPidPath(sessionName: string): string { - return path.join(this.resolveSessionDir(sessionName), 'app-log.pid'); + return resolveSessionAppLogPidPath(this.sessionsDir, sessionName); } resolveEventLogPath(sessionName: string): string { return resolveSessionEventLogPath(this.resolveSessionDir(sessionName)); } - - static expandHome(filePath: string, cwd?: string): string { - return expandSessionPath(filePath, cwd); - } - - /** - * Resolve the map key for a live session object. SessionState.name is the - * public session name, while the map key may include cwd/tenant isolation. - */ - resolveStoredSessionName(session: SessionState): string { - for (const [name, value] of this.sessions) { - if (value === session) return name; - } - return session.name; - } } diff --git a/src/daemon/session-teardown.ts b/src/daemon/session-teardown.ts index 377be3fb2b..e9e684fbef 100644 --- a/src/daemon/session-teardown.ts +++ b/src/daemon/session-teardown.ts @@ -1,10 +1,13 @@ import { AppError } from '@agent-device/kernel/errors'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { cleanupRetainedMaterializedPathsForSession } from './materialized-path-registry.ts'; -import type { SessionState } from './session-state.ts'; +import type { SessionRef, SessionState } from './session-state.ts'; +import { + bindSessionAudioProbe, + bindSessionPerfCapture, + bindSessionScreenRecording, +} from './session-capture-binding.ts'; import type { SessionStore } from './session-store.ts'; -import { forceCleanupSessionAppLog } from './app-log-session-resource.ts'; -import { appLogResourceStore } from './app-log-resource-store.ts'; import { finishLiveAudioProbe } from '@agent-device/capture-kit/audio-probe-session-resource'; import { finishLivePerfCapture } from '@agent-device/capture-kit/perf-capture-session-resource'; import { finishLiveScreenRecording } from '@agent-device/capture-kit/screen-recording-session-resource'; @@ -12,28 +15,21 @@ import { openWebSessionNames } from './web-session-names.ts'; import type { PlatformResourceCleanup } from './platform-resource-cleanup.ts'; export async function stopSessionAppLog(params: { - session: SessionState; - sessionName: string; + ref: SessionRef; sessionStore: SessionStore; }): Promise { - const { session, sessionName, sessionStore } = params; - if (!session.appLog) return; - await forceCleanupSessionAppLog({ - session, - sessionName, - sessionStore, - resourcePath: appLogResourceStore.resolvePath(sessionStore.resolveSessionDir(sessionName)), - }); + const ref = params.sessionStore.refresh(params.ref); + if (!ref.session.appLog) return; + const { forceCleanupSessionAppLog } = await import('./app-log-session-resource.ts'); + await forceCleanupSessionAppLog({ ...params, ref }); } export async function stopSessionPerfCapture(params: { - session: SessionState; - sessionName: string; + ref: SessionRef; sessionStore: SessionStore; }): Promise { - const currentSession = params.sessionStore.get(params.sessionName) ?? params.session; - if (!currentSession.perfCapture) return; - await finishLivePerfCapture({ ...params, session: currentSession, intent: 'disposal' }); + const binding = bindSessionPerfCapture(params.sessionStore, params.ref); + if (binding.read()) await finishLivePerfCapture({ binding, intent: 'disposal' }); } export async function stopSessionSnapshotHelper( @@ -51,12 +47,13 @@ export async function stopSessionSnapshotHelper( // siblings above, this has no second caller in the ordinary-close path (that path already // reaches the browser through `dispatchTargetedPlatformClose`), so it stays module-private. async function stopSessionWebBrowser(params: { - session: SessionState; - sessionName: string; + ref: SessionRef; sessionStore: SessionStore; platformCleanup: PlatformResourceCleanup; }): Promise { - const { session, sessionName, sessionStore, platformCleanup } = params; + const { ref, sessionStore, platformCleanup } = params; + const session = sessionStore.resolveCurrent(ref) ?? ref.session; + const sessionName = ref.address; await platformCleanup.closeManagedBrowser({ device: session.device, sessionName, @@ -120,8 +117,7 @@ export function reportSessionCleanupFailures(params: { } type SessionResourceTeardownRequest = { - session: SessionState; - sessionName: string; + ref: SessionRef; sessionStore: SessionStore; stateDir?: string; appLog: 'run' | 'already-settled'; @@ -131,7 +127,9 @@ type SessionResourceTeardownRequest = { export async function teardownSessionResources( request: SessionResourceTeardownRequest, ): Promise { - const { session, sessionName, sessionStore } = request; + const { ref, sessionStore } = request; + const session = sessionStore.resolveCurrent(ref) ?? ref.session; + const sessionName = ref.address; if (!request.platformCleanup) { throw new AppError( 'INTERNAL_ERROR', @@ -144,7 +142,7 @@ export async function teardownSessionResources( ? [ { step: 'app_log', - run: () => stopSessionAppLog({ session, sessionName, sessionStore }), + run: () => stopSessionAppLog({ ref, sessionStore }), }, ] : []; @@ -158,19 +156,18 @@ export async function teardownSessionResources( step: 'recording', run: () => finishSessionScreenRecording({ - session, - sessionName, + ref, sessionStore, }), }, ...appLogSteps, { step: 'audio_probe', - run: () => finishSessionAudioProbe({ session, sessionName, sessionStore }), + run: () => finishSessionAudioProbe({ ref, sessionStore }), }, { step: 'perf_capture', - run: () => stopSessionPerfCapture({ session, sessionName, sessionStore }), + run: () => stopSessionPerfCapture({ ref, sessionStore }), }, { step: 'platform_snapshot_helper', @@ -183,8 +180,7 @@ export async function teardownSessionResources( step: 'web_browser', run: () => stopSessionWebBrowser({ - session, - sessionName, + ref, sessionStore, platformCleanup, }), @@ -204,31 +200,17 @@ export async function teardownSessionResources( } export async function finishSessionScreenRecording(params: { - session: SessionState; - sessionName: string; + ref: SessionRef; sessionStore: SessionStore; }): Promise { - const currentSession = params.sessionStore.get(params.sessionName) ?? params.session; - if (!currentSession.screenRecording) return; - await finishLiveScreenRecording({ - intent: 'disposal', - session: currentSession, - sessionName: params.sessionName, - sessionStore: params.sessionStore, - }); + const binding = bindSessionScreenRecording(params.sessionStore, params.ref); + if (binding.read()) await finishLiveScreenRecording({ binding, intent: 'disposal' }); } export async function finishSessionAudioProbe(params: { - session: SessionState; - sessionName: string; + ref: SessionRef; sessionStore: SessionStore; }): Promise { - const currentSession = params.sessionStore.get(params.sessionName) ?? params.session; - if (!currentSession.audioProbe) return; - await finishLiveAudioProbe({ - intent: 'disposal', - session: currentSession, - sessionName: params.sessionName, - sessionStore: params.sessionStore, - }); + const binding = bindSessionAudioProbe(params.sessionStore, params.ref); + if (binding.read()) await finishLiveAudioProbe({ binding, intent: 'disposal' }); } diff --git a/src/daemon/snapshot-command-runtime.ts b/src/daemon/snapshot-command-runtime.ts index a83882e039..48d460825d 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,33 @@ 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 { req, logPath, sessionStore } = params; + const sessionName = ref?.address ?? params.sessionName; 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 +98,16 @@ export async function dispatchSnapshotRuntimeCommand( if (!timeoutResponse) throw error; return timeoutResponse; } + const current = getSession(); recordSnapshotRuntimeAction({ req, sessionName, sessionStore, + ref: sessions.getRef(), 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 +126,7 @@ function createSnapshotRuntime(params: { sessionName: string; logPath: string; sessionStore: SessionStore; + ref: SessionRef | undefined; session: SessionState | undefined; device: SessionState['device']; snapshotScope: string | undefined; @@ -125,7 +134,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 = ref + ? sessionStore.update(ref, {}) + : 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 +187,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, - }); - setSnapshotLineage(nextSession, { - scopeSource: resolveNextSnapshotScopeSource({ - current, - keepCurrentSnapshot, - refScopedSnapshot, - }), - keptCurrentSnapshot: keepCurrentSnapshot, - previousGeneration: current?.snapshotGeneration, + sessions, }); - reactivateCompleteFrameIfIssuing(nextSession, keepCurrentSnapshot, params.issuesRefsToClient); - if (record.appName) nextSession.appName = record.appName; - return nextSession; + return { runtime, sessions }; } function isRefScopedSnapshot(req: DaemonRequest): boolean { @@ -273,11 +267,11 @@ function recordSnapshotRuntimeAction(params: { req: DaemonRequest; sessionName: string; sessionStore: SessionStore; + ref: SessionRef | undefined; result: SnapshotRuntimeRecord; }): void { - const session = params.sessionStore.get(params.sessionName); - if (!session) return; - params.sessionStore.recordAction(session, { + if (!params.ref) return; + params.sessionStore.recordAction(params.ref, { command: params.req.command, positionals: params.req.positionals ?? [], flags: params.req.flags ?? {}, diff --git a/src/daemon/snapshot-runtime-binding.ts b/src/daemon/snapshot-runtime-binding.ts index 909e4734a4..961dd40fd8 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, @@ -41,6 +41,7 @@ import { errorResponse } from '@agent-device/kernel/contracts'; export type SnapshotRuntimeRouteParams = { req: DaemonRequest; sessionName: string; + sessionRef?: SessionRef; logPath: string; sessionStore: SessionStore; inspectFacts?: InspectDeviceRuntimeFacts; @@ -51,6 +52,7 @@ export type SnapshotRuntimeRouteParams = { type ResolvedSnapshotCaptureRuntime = | Readonly<{ ok: true; + ref: SessionRef | undefined; session: SessionState | undefined; device: SessionState['device']; snapshotScope: string | undefined; @@ -134,7 +136,12 @@ 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, + params.sessionRef, + ); const resolvedScope = resolveSnapshotScope(req.flags?.snapshotScope, session); if (!resolvedScope.ok) return { ok: false, response: resolvedScope }; @@ -161,6 +168,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..a30e5f07d0 100644 --- a/src/daemon/snapshot-session.ts +++ b/src/daemon/snapshot-session.ts @@ -1,7 +1,7 @@ import { resolveTargetDevice } from '@agent-device/device-selection/dispatch-resolve'; import type { PlatformResourceCleanup } from './platform-resource-cleanup.ts'; import type { DaemonRequest } from './daemon-request.ts'; -import type { SessionState } from './session-state.ts'; +import type { SessionRef, SessionState } from './session-state.ts'; import type { SessionScope } from '@agent-device/contracts/session'; import { isActiveProviderDevice } from './provider-device-admission.ts'; import { SessionStore } from './session-store.ts'; @@ -10,10 +10,12 @@ export async function resolveSessionDevice( sessionStore: SessionStore, sessionName: string, flags: DaemonRequest['flags'], + boundRef?: SessionRef, ) { - const session = sessionStore.get(sessionName); + const ref = boundRef ?? sessionStore.lookup(sessionName); + const session = ref ? sessionStore.requireCurrent(ref) : undefined; const device = session?.device ?? (await resolveTargetDevice(flags ?? {})); - return { session, device }; + return { ref, session, device }; } export async function withSessionlessRunnerCleanup( @@ -39,12 +41,12 @@ export async function withSessionlessRunnerCleanup( export function recordIfSession( sessionStore: SessionStore, - session: SessionState | undefined, + ref: SessionRef | undefined, req: DaemonRequest, result: Record, ): void { - if (!session) return; - sessionStore.recordAction(session, { + if (!ref) return; + sessionStore.recordAction(ref, { command: req.command, positionals: req.positionals ?? [], flags: req.flags ?? {}, @@ -52,23 +54,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..9cd1281cd7 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, @@ -170,7 +171,7 @@ async function executeWaitRequest( const recordedTarget = readRecordedResolutionTarget(result); recordIfSession( sessionStore, - sessionName, + params.ref, req, stripResolutionPayload(result), recordedTarget, 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 }, diff --git a/test/integration/nightly/concurrency-torture/bindings.ts b/test/integration/nightly/concurrency-torture/bindings.ts index 06fc88d0b1..267aac83c9 100644 --- a/test/integration/nightly/concurrency-torture/bindings.ts +++ b/test/integration/nightly/concurrency-torture/bindings.ts @@ -21,6 +21,7 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import type { CommandFlags } from '@agent-device/contracts/command'; import type { DaemonRequest } from '../../../../src/daemon/daemon-request.ts'; +import type { SessionRef } from '../../../../src/daemon/session-state.ts'; import type { SessionStore } from '../../../../src/daemon/session-store.ts'; import { resolveRequestExecutionLockPlan } from '../../../../src/daemon/request-binding.ts'; import { shouldLockSessionExecution } from '../../../../src/daemon/daemon-command-registry.ts'; @@ -71,6 +72,7 @@ export type LeaseScope = { * reference can never be mistaken for a live one. */ export type SessionInstance = { + ref: SessionRef; instanceId: number; name: string; deviceId: string; diff --git a/test/integration/nightly/concurrency-torture/harness.ts b/test/integration/nightly/concurrency-torture/harness.ts index f300b586c9..6cabd1ab17 100644 --- a/test/integration/nightly/concurrency-torture/harness.ts +++ b/test/integration/nightly/concurrency-torture/harness.ts @@ -403,9 +403,10 @@ class TortureWorld { ownerStartTime: null, }, }; - this.sessionStore.set(name, state); + const ref = this.sessionStore.publish(name, state); const instanceId = this.nextInstanceId++; const instance: SessionInstance = { + ref, instanceId, name, deviceId: device.id, @@ -449,10 +450,7 @@ class TortureWorld { this.fail('no leaked leases', `release threw for ${instance.name}: ${error.message}`); } this.claims.clear(instance.claim); - const stored = this.sessionStore.get(instance.name); - if (stored?.lease?.leaseId === instance.lease.leaseId) { - this.sessionStore.delete(instance.name); - } + this.sessionStore.retire(instance.ref); instance.reaped = true; if (this.deviceOwner.get(instance.deviceId) === instance.instanceId) { this.deviceOwner.delete(instance.deviceId); diff --git a/test/integration/provider-scenarios/android-tv-remote.test.ts b/test/integration/provider-scenarios/android-tv-remote.test.ts index cb279667fb..bb9baccd7d 100644 --- a/test/integration/provider-scenarios/android-tv-remote.test.ts +++ b/test/integration/provider-scenarios/android-tv-remote.test.ts @@ -31,7 +31,7 @@ test('Provider-backed integration Android TV remote flow sends D-pad keyevents', deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID_TV], }), async (daemon) => { - daemon.setSession('default', { + daemon.publishSession('default', { name: 'default', device: PROVIDER_SCENARIO_ANDROID_TV, createdAt: Date.now(), diff --git a/test/integration/provider-scenarios/apple-app-log-runtime-provider.test.ts b/test/integration/provider-scenarios/apple-app-log-runtime-provider.test.ts index 61b8d33646..19365aabec 100644 --- a/test/integration/provider-scenarios/apple-app-log-runtime-provider.test.ts +++ b/test/integration/provider-scenarios/apple-app-log-runtime-provider.test.ts @@ -16,7 +16,7 @@ test('provider-inventory Apple logs doctor stays on the scoped Apple tool provid appleToolProvider: () => appleTool.provider, deviceInventoryProvider: async () => [PROVIDER_SCENARIO_IOS_SIMULATOR], }); - daemon.setSession('default', { + daemon.publishSession('default', { name: 'default', device: PROVIDER_SCENARIO_IOS_SIMULATOR, appBundleId: 'com.example.app', diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index 01159d16bb..34c024dba6 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -83,7 +83,7 @@ export type ProviderScenarioHarness = { token: string; session: (name?: string) => SessionState | undefined; sessionDir: (name?: string) => string; - setSession: (name: string, session: SessionState) => void; + publishSession: (name: string, session: SessionState) => void; close: () => Promise; }; @@ -229,7 +229,7 @@ export async function createProviderScenarioHarness( token: PROVIDER_SCENARIO_TOKEN, session: (name = 'default') => sessionStore.get(name), sessionDir: (name = 'default') => sessionStore.resolveSessionDir(name), - setSession: (name, session) => sessionStore.set(name, session), + publishSession: (name, session) => sessionStore.publish(name, session), close: async () => { await deviceRuntimeGateway.shutdown(); await removeProviderScenarioTempDir(sessionDir); diff --git a/test/integration/provider-scenarios/ios-fold.test.ts b/test/integration/provider-scenarios/ios-fold.test.ts index 6dc08a5000..186f1bb774 100644 --- a/test/integration/provider-scenarios/ios-fold.test.ts +++ b/test/integration/provider-scenarios/ios-fold.test.ts @@ -105,7 +105,7 @@ test('timed fold keyframes reach simulator HID through the public client and dae }, }), }); - daemon.setSession( + daemon.publishSession( 'default', makeIosAppSession('default', { device: PROVIDER_SCENARIO_IOS_SIMULATOR }), ); @@ -278,7 +278,7 @@ async function runFoldLedgerScenario(params: { deviceInventoryProvider: async () => [PROVIDER_SCENARIO_IOS_SIMULATOR], appleToolProvider: () => provider, }); - daemon.setSession( + daemon.publishSession( 'default', makeIosAppSession('default', { device: PROVIDER_SCENARIO_IOS_SIMULATOR }), ); diff --git a/test/integration/provider-scenarios/ios-wait-selector.test.ts b/test/integration/provider-scenarios/ios-wait-selector.test.ts index 0feac0c5e1..4894ce235b 100644 --- a/test/integration/provider-scenarios/ios-wait-selector.test.ts +++ b/test/integration/provider-scenarios/ios-wait-selector.test.ts @@ -54,7 +54,7 @@ test.each([ }, }), }); - daemon.setSession('default', { + daemon.publishSession('default', { ...makeIosAppSession('default'), device: PROVIDER_SCENARIO_IOS_SIMULATOR, }); diff --git a/test/integration/provider-scenarios/network-runtime-provider.test.ts b/test/integration/provider-scenarios/network-runtime-provider.test.ts index 3172f7d8e2..84f61db51c 100644 --- a/test/integration/provider-scenarios/network-runtime-provider.test.ts +++ b/test/integration/provider-scenarios/network-runtime-provider.test.ts @@ -20,7 +20,7 @@ test('iOS simulator network recovery stays on the request-scoped Apple tool prov appleToolProvider: () => appleTool.provider, deviceInventoryProvider: async () => [PROVIDER_SCENARIO_IOS_SIMULATOR], }); - daemon.setSession('default', { + daemon.publishSession('default', { name: 'default', device: PROVIDER_SCENARIO_IOS_SIMULATOR, appBundleId: 'com.example.app', diff --git a/test/integration/provider-scenarios/stale-provider-runtime-admission.fixtures.ts b/test/integration/provider-scenarios/stale-provider-runtime-admission.fixtures.ts index cf618d1f0b..8b6895ff1b 100644 --- a/test/integration/provider-scenarios/stale-provider-runtime-admission.fixtures.ts +++ b/test/integration/provider-scenarios/stale-provider-runtime-admission.fixtures.ts @@ -74,7 +74,7 @@ export async function inspectProviderDeploymentAdmission(params: { fs.writeFileSync(appPath, 'fixture'); try { - sessionStore.set('default', { + sessionStore.publish('default', { name: 'default', createdAt: 1, actions: [], diff --git a/test/integration/provider-scenarios/vega-tv-remote.test.ts b/test/integration/provider-scenarios/vega-tv-remote.test.ts index 0fdd4c4c6e..3ddad7f116 100644 --- a/test/integration/provider-scenarios/vega-tv-remote.test.ts +++ b/test/integration/provider-scenarios/vega-tv-remote.test.ts @@ -54,7 +54,7 @@ test('Provider-backed Vega TV flow routes lifecycle and every remote control as deviceInventoryProvider: async () => [VEGA_TV], }), async (daemon) => { - daemon.setSession('default', { + daemon.publishSession('default', { name: 'default', device: VEGA_TV, createdAt: Date.now(), diff --git a/test/integration/support/daemon-test-cleanup.test.ts b/test/integration/support/daemon-test-cleanup.test.ts new file mode 100644 index 0000000000..baba38e0a6 --- /dev/null +++ b/test/integration/support/daemon-test-cleanup.test.ts @@ -0,0 +1,145 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { test, vi } from 'vitest'; +import { + isProcessAlive, + readHostProcessIdentityObservations, +} from '@agent-device/host-kit/process'; +import { cleanupDaemonTestState } from './daemon-test-cleanup.ts'; +import { resolveDaemonPaths } from '../../../src/daemon-resolution.ts'; +import { stopDaemonProcess } from '../../../src/daemon-process.ts'; +import { mkdtempForTestSync } from '../../../src/__tests__/test-utils/tmp-dir.ts'; +import { + spawnRegisteredDaemonFixture, + waitForRegisteredDaemonFixture, + finishRegisteredDaemonFixture, +} from '../../../src/__tests__/test-utils/registered-daemon-fixture.ts'; + +const fields = { + httpPort: 4210, + token: 'fixture', + version: 'test', + codeOrigin: 'checkout' as const, + codeSignature: 'fixture', +}; + +test.each(['string', 'out-of-range'])( + 'malformed %s registration retains the directory and live daemon', + async (kind) => { + const paths = resolveDaemonPaths(mkdtempForTestSync('daemon-test-invalid-registration-')); + const child = spawnRegisteredDaemonFixture(paths, fields, undefined); + const warnings = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const info = await waitForRegisteredDaemonFixture(paths, child); + const malformed = JSON.stringify({ + ...info, + pid: kind === 'string' ? String(info.pid) : 2_147_483_648, + }); + fs.writeFileSync(paths.infoPath, malformed); + await cleanupDaemonTestState(paths.baseDir, null); + assert.equal(fs.readFileSync(paths.infoPath, 'utf8'), malformed); + assert.equal(isProcessAlive(child.pid), true); + assert.equal(warnings.mock.calls.length, 1); + } finally { + warnings.mockRestore(); + await finishRegisteredDaemonFixture(paths.baseDir); + } + }, +); + +test.each([false, true])( + 'cleanup joins a registered successor when the old observation lacks birth proof: %s', + async (missingBirth) => { + const paths = resolveDaemonPaths(mkdtempForTestSync('daemon-test-replaced-registration-')); + const original = spawnRegisteredDaemonFixture(paths, fields, undefined); + try { + const observed = await waitForRegisteredDaemonFixture(paths, original); + const stopped = await stopDaemonProcess( + { pid: original.pid, startTime: observed.processStartTime ?? null }, + { mode: 'force', termTimeoutMs: 0, killTimeoutMs: 1_000 }, + ); + assert.equal(stopped.status, 'exited'); + await original.exited; + const replacement = spawnRegisteredDaemonFixture(paths, fields, undefined); + await waitForRegisteredDaemonFixture(paths, replacement); + await cleanupDaemonTestState(paths.baseDir, { + ...observed, + processStartTime: missingBirth ? undefined : observed.processStartTime, + }); + assert.ok( + !isProcessAlive(replacement.pid) || + readHostProcessIdentityObservations([replacement.pid]) + .get(replacement.pid) + ?.state.startsWith('Z'), + 'the registered replacement must be dead before cleanup returns', + ); + await replacement.exited; + assert.equal(isProcessAlive(replacement.pid), false); + assert.equal(fs.existsSync(paths.baseDir), false); + } finally { + await finishRegisteredDaemonFixture(paths.baseDir); + } + }, +); + +test.each(['invalid-json', 'ownerless'])( + 'cleanup joins its observed child and retains %s metadata', + async (kind) => { + const paths = resolveDaemonPaths(mkdtempForTestSync('daemon-test-corrupt-observed-')); + const child = spawnRegisteredDaemonFixture(paths, fields, undefined); + const warnings = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const observed = await waitForRegisteredDaemonFixture(paths, child); + const corrupt = kind === 'invalid-json' ? '{invalid' : '{"pid": "unknown"}'; + fs.writeFileSync(paths.infoPath, corrupt); + await cleanupDaemonTestState(paths.baseDir, observed); + assert.ok( + !isProcessAlive(child.pid) || + readHostProcessIdentityObservations([child.pid]).get(child.pid)?.state.startsWith('Z'), + 'the observed child must be terminated before cleanup returns', + ); + await child.exited; + assert.equal(isProcessAlive(child.pid), false); + assert.equal(fs.readFileSync(paths.infoPath, 'utf8'), corrupt); + assert.equal(warnings.mock.calls.length, 1); + } finally { + warnings.mockRestore(); + await finishRegisteredDaemonFixture(paths.baseDir); + } + }, +); + +test('cleanup retains an unpublished successor holding the registration lock', async () => { + const paths = resolveDaemonPaths(mkdtempForTestSync('daemon-test-unpublished-successor-')); + const original = spawnRegisteredDaemonFixture(paths, fields, undefined); + const warnings = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const observed = await waitForRegisteredDaemonFixture(paths, original); + assert.equal( + ( + await stopDaemonProcess( + { pid: original.pid, startTime: observed.processStartTime ?? null }, + { mode: 'force', termTimeoutMs: 0, killTimeoutMs: 1_000 }, + ) + ).status, + 'exited', + ); + await original.exited; + fs.rmSync(paths.infoPath); + fs.rmSync(paths.baseDir + '/registration-held'); + fs.writeFileSync(paths.baseDir + '/defer-publication', 'wait'); + const successor = spawnRegisteredDaemonFixture(paths, fields, undefined); + await vi.waitFor( + () => assert.equal(fs.existsSync(paths.baseDir + '/registration-held'), true), + { timeout: 4_000, interval: 10 }, + ); + await cleanupDaemonTestState(paths.baseDir, observed); + assert.equal(fs.existsSync(paths.baseDir), true); + assert.equal(fs.existsSync(paths.infoPath), false); + assert.equal(isProcessAlive(successor.pid), true); + assert.equal(warnings.mock.calls.length, 1); + } finally { + warnings.mockRestore(); + await finishRegisteredDaemonFixture(paths.baseDir); + } +}); diff --git a/test/integration/support/daemon-test-cleanup.ts b/test/integration/support/daemon-test-cleanup.ts index 371159c521..aa5c64fe75 100644 --- a/test/integration/support/daemon-test-cleanup.ts +++ b/test/integration/support/daemon-test-cleanup.ts @@ -1,7 +1,17 @@ import fs from 'node:fs'; -import path from 'node:path'; import { normalizeError } from '@agent-device/kernel/errors'; +import { tryAcquireProcessLock } from '@agent-device/host-kit/file'; +import { + readCurrentOwnerIdentity, + ownerIdentityMatches, + type OwnerIdentity, +} from '@agent-device/host-kit/process'; import { stopDaemonProcess } from '../../../src/daemon-process.ts'; +import { resolveDaemonPaths } from '../../../src/daemon-resolution.ts'; +import { + readRegisteredDaemonIdentity, + readRegisteredDaemonOwnership, +} from '../../../src/daemon-registration.ts'; type TestDaemonIdentity = { pid: number; processStartTime?: string }; @@ -11,29 +21,57 @@ export async function cleanupDaemonTestState( observed: TestDaemonIdentity | null, ): Promise { try { - const identity = observed ?? readIdentity(stateDir); - if (!identity) throw new Error('No daemon lifetime was observed'); - const termination = await stopDaemonProcess( - { pid: identity.pid, startTime: identity.processStartTime ?? null }, - { mode: 'graceful', termTimeoutMs: 1_500, killTimeoutMs: 1_500 }, - ); - if (termination.status !== 'exited') { - console.warn('Daemon test cleanup retained state:', stateDir, termination); - return; + const paths = resolveDaemonPaths(stateDir); + const identities: OwnerIdentity[] = observed + ? [{ pid: observed.pid, startTime: observed.processStartTime ?? null }] + : []; + let registrationFailure: unknown; + try { + const registered = readIdentity(paths.infoPath); + if (registered) identities.push(registered); + } catch (error) { + registrationFailure = error; + } + const confirmed: OwnerIdentity[] = []; + let retained = false; + for (const identity of identities) { + const termination = await stopDaemonProcess(identity, { + mode: 'graceful', + termTimeoutMs: 1_500, + killTimeoutMs: 1_500, + }); + if (termination.status === 'exited') confirmed.push(identity); + else if (termination.status === 'retained') retained = true; + } + if (registrationFailure) throw registrationFailure; + if (retained || confirmed.length === 0) + throw new Error('Daemon termination could not be confirmed'); + const attempt = tryAcquireProcessLock({ + lockDirPath: paths.lockPath, + owner: { ...readCurrentOwnerIdentity(), acquiredAtMs: Date.now() }, + description: 'daemon test cleanup', + }); + if (attempt.status !== 'acquired') + throw new Error('Daemon registration is held or unproven during test cleanup'); + const { acquisition } = attempt; + try { + acquisition.assertHeld(); + const current = readIdentity(paths.infoPath); + if (current && !confirmed.some((identity) => ownerIdentityMatches(identity, current))) + throw new Error('Daemon registration changed during test cleanup'); + acquisition.assertHeld(); + fs.rmSync(stateDir, { recursive: true, force: true }); + } finally { + await acquisition.release(); } - fs.rmSync(stateDir, { recursive: true, force: true }); } catch (error) { console.warn('Daemon test cleanup retained state:', stateDir, normalizeError(error)); } } -function readIdentity(stateDir: string): TestDaemonIdentity | null { - try { - return JSON.parse( - fs.readFileSync(path.join(stateDir, 'daemon.json'), 'utf8'), - ) as TestDaemonIdentity; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; - throw error; - } +function readIdentity(infoPath: string): OwnerIdentity | null { + if (readRegisteredDaemonOwnership(infoPath, null).state === 'absent') return null; + const identity = readRegisteredDaemonIdentity(infoPath); + if (!identity) throw new Error('Daemon registration identity is invalid or unreadable'); + return identity; } diff --git a/test/wire-compat/ledger.json b/test/wire-compat/ledger.json index 80e13dc3e9..20fc434604 100644 --- a/test/wire-compat/ledger.json +++ b/test/wire-compat/ledger.json @@ -67,7 +67,7 @@ "src/daemon-client/daemon-client-rpc.ts#toDaemonHttpRpcError": "sha256:888246763c48670e7da893054d025744654f8715c3b4906312617a2b5028316b", "src/daemon-client/daemon-client-transport.ts#RemoteDaemonHealth": "sha256:3bac36fa97090b273afe1128103e1bf476d05441fd9de41317f1b78775b88304", "src/daemon-client/daemon-client-transport.ts#RemoteDaemonHealthLink": "sha256:7702598468b4c82b4ffa93064cd6bdfec938c1c527f7e6007c0584f3884a6eea", - "src/daemon-client/daemon-client-transport.ts#readDaemonHttpHealth": "sha256:eef0e153eb6f0bc02175e464dd6d98559b500b65ea8c74ba2203cfef09ec09a0", + "src/daemon-client/daemon-client-transport.ts#readDaemonHttpHealth": "sha256:f40cc2f5bfb8add78f99b11db7842bc244735786aa390d10a38ef025e16dc53a", "src/daemon-client/daemon-client-transport.ts#readHealthLink": "sha256:f56404b94d73da57de7248719c9136b760d2be8b4a53cde5315a2311b088425b", "src/daemon-client/daemon-client-transport.ts#readHealthPayload": "sha256:4e85ffc3e35e02379c393e9312344757e003cf1f0ad9eb8d1f77d90c81c861f1", "src/daemon-client/daemon-client-transport.ts#readRemoteDaemonHealth": "sha256:bcefa89fbb7fcbd6fee1b5ecb217955b9eee8d6fd2ad175fb653011edbd199d9", @@ -431,8 +431,8 @@ }, { "declaration": "src/daemon-client/daemon-client-transport.ts#readDaemonHttpHealth", - "digest": "sha256:eef0e153eb6f0bc02175e464dd6d98559b500b65ea8c74ba2203cfef09ec09a0", - "rationale": "A restart retry must tell a probe that ran out of time from one that failed, so the client can report the RPC deadline instead of 'Remote daemon is unavailable'. `timedOut` is client-local: the prober sets it on its own timeout and abort paths and never reads it from a /health payload. The health request and the accepted payload fields are unchanged, so a released daemon or proxy is probed and parsed exactly as before." + "digest": "sha256:f40cc2f5bfb8add78f99b11db7842bc244735786aa390d10a38ef025e16dc53a", + "rationale": "#3116 includes requester loading and response completion in the existing health-probe budget. `timedOut` remains client-local; it is never read from a /health payload. Request routes, authentication, accepted fields and protocol-version admission are unchanged, so released daemons and proxies still send payloads this client parses correctly. This is an implementation-only timing change under ADR 0006." } ] } diff --git a/vitest.config.ts b/vitest.config.ts index 1b4390fc19..33237c9ecf 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -178,6 +178,8 @@ export default defineConfig({ // decisions over fixture state-dir listings, so they need no daemon, // device, or subprocess. 'test/integration/support/daemon-leak-model.test.ts', + // Cleanup uses real detached registration owners and process-exit proof; no device is needed. + 'test/integration/support/daemon-test-cleanup.test.ts', // The Android failed-step evidence reader: it replays adb output through the probe // seam, so the crash/process/activity selectors need no emulator to be pinned. 'test/integration/android-emulator-e2e/device-evidence.test.ts', diff --git a/website/docs/docs/installation.md b/website/docs/docs/installation.md index eca4ff398d..cef44e2760 100644 --- a/website/docs/docs/installation.md +++ b/website/docs/docs/installation.md @@ -115,10 +115,15 @@ vega device list - A runner startup failure is typed, not prose: `error.details.reason` is one of `signing_no_development_team`, `signing_provisioning_profile_missing`, `bundle_identifier_already_registered`, `signing_unspecified`, `devtools_security_developer_mode_disabled` (the Mac's `DevToolsSecurity` setting, which says nothing about the device's Developer Mode toggle), `device_developer_mode_disabled`, `device_developer_disk_image_unavailable`, or `build_failed_unclassified` when nothing proved a cause. Branch on `details.reason` and follow `hint`; the code stays `COMMAND_FAILED` for every reason. - The two `device_*` reasons come from the iPhone itself, read over `xcrun devicectl device info details` before the runner builds: `developerModeStatus` for the Settings toggle and `ddiServicesAvailable` for the developer disk image. They are reported apart on purpose. A phone with Developer Mode off cannot serve its disk image either, so it gets the toggle reason; a phone with the toggle on and only the image down gets the disk-image reason, which is a device-support install that has not finished rather than a setting anyone turned off. - If device setup is slow, keep the device connected and inspect daemon diagnostics after retrying. -- If daemon startup reports stale metadata, remove stale files and retry: - - `/daemon.json` - - `/daemon.lock` - - default state dir is `~/.agent-device` for packaged installs; source checkouts default to a worktree-scoped dir under `~/.agent-device/dev/` unless `AGENT_DEVICE_STATE_DIR` or `--state-dir` is set - - `agent-device session state-dir` prints the resolved state dir without starting the daemon - - after pulling the worktree-scoped daemon change in a source checkout, stop any legacy default daemon once with `AGENT_DEVICE_STATE_DIR=~/.agent-device pnpm clean:daemon` - - worktree-scoped state dirs outlive deleted worktrees; `pnpm clean:daemon --prune-dev` removes dirs under `~/.agent-device/dev/` with no live daemon and no activity for 14 days (one line printed per removed dir) + +## Daemon startup and upgrades + +If daemon startup fails, retry with `--debug` and inspect the retained state and diagnostics. `agent-device session state-dir` prints the resolved directory without starting a daemon. + +Before upgrading across the daemon lock change, stop every older client and daemon using that directory with their original CLI. Prevent older versions from returning while the upgraded version runs. Use a single deployed version or separate environments for concurrent installations. + +Startup refuses legacy lock files and unverified ownership. Confirm every user of the state directory stopped before manual recovery; removing `daemon.json` or `daemon.lock` alone is not a safe reset. + +Packaged installs default to `~/.agent-device`; source checkouts use a worktree directory under `~/.agent-device/dev/`. `AGENT_DEVICE_STATE_DIR` or `--state-dir` overrides either default. + +For source checkouts, `pnpm clean:daemon --prune-dev` selects development directories with no activity for 14 days, using the newest mtime of the directory and its immediate children. It retires only registration it can confirm abandoned. Directories, session artifacts and logs remain.