From be6e94252657daed88c1b16e4dd590b345cfbdee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 22:37:54 +0200 Subject: [PATCH 01/14] refactor: bind session resources to stable lifetimes --- .../audio-probe-session-resource.test.ts | 13 +- .../durable-capture-resource.fixtures.ts | 16 +- .../durable-capture-resource.test.ts | 4 +- .../screen-recording-boundary-faults.test.ts | 13 +- .../screen-recording-session-resource.test.ts | 40 +++-- .../__tests__/session-store.fixtures.ts | 28 +-- .../audio-probe-session-resource.ts | 18 +- .../durable-capture-resource.ts | 43 ++--- .../perf-capture-session-resource.ts | 18 +- .../screen-recording-session-resource.ts | 18 +- .../screen-recording-stop-recovery.ts | 8 +- .../src/durable-capture/adoption.test.ts | 22 +++ .../src/durable-capture/adoption.ts | 89 +++++----- .../src/durable-capture/definition.ts | 36 ++-- .../durable-capture.fixtures.ts | 29 ++-- .../capture-kit/src/durable-capture/index.ts | 3 +- .../session-binding.fixtures.ts | 87 ++++++++++ .../src/durable-capture/transitions.test.ts | 88 ++++++++-- .../src/durable-capture/transitions.ts | 42 ++--- .../src/recording/failed-finish.test.ts | 71 ++++---- .../session-resource-ownership.test.ts | 12 +- .../layering/session-resource-ownership.ts | 12 +- src/__tests__/test-utils/store-factory.ts | 5 + .../app-log-session-resource.test.ts | 160 +++++++++++++++++- .../__tests__/daemon-runtime-app-log.test.ts | 2 +- src/daemon/__tests__/lease-lifecycle.test.ts | 4 +- .../perf-capture-session-resource.test.ts | 10 +- ...quest-execution-scope-lease-expiry.test.ts | 132 +++++++++++++++ .../__tests__/request-execution-scope.test.ts | 41 ----- .../request-lock-identity-policy.test.ts | 3 +- .../__tests__/request-lock-policy.test.ts | 3 +- .../screen-recording-session-binding.test.ts | 60 +++++++ .../__tests__/session-capture-binding.test.ts | 111 ++++++++++++ src/daemon/__tests__/session-selector.test.ts | 3 +- .../__tests__/session-store-lifetime.test.ts | 123 ++++++++++++++ src/daemon/app-log-session-resource.ts | 61 ++++--- src/daemon/handlers/record-runtime.ts | 56 +++--- src/daemon/lease-lifecycle.ts | 25 +-- src/daemon/request-execution-scope.ts | 20 +-- .../screen-recording-session-binding.ts | 33 ++++ .../daemon-runtime-device-claims.test.ts | 4 +- .../daemon-runtime-recording-teardown.test.ts | 4 +- .../daemon-runtime-web-close-teardown.test.ts | 6 +- src/daemon/server/daemon-runtime.ts | 30 ++-- src/daemon/session-capture-binding.ts | 87 ++++++++++ .../session-open-device-in-use.test.ts | 17 +- .../session-teardown-resources.test.ts | 37 ++-- .../session-close-lifecycle-teardown.ts | 30 ++-- .../internal/session-close.ts | 20 +-- .../internal/__tests__/session-logs.test.ts | 2 +- .../__tests__/session-perf-runtime.test.ts | 36 ++++ .../internal/session-audio.ts | 60 +++---- .../internal/session-observability.ts | 31 ++-- .../internal/session-perf-runtime.ts | 32 ++-- src/daemon/session-recovery-hints.test.ts | 19 +-- src/daemon/session-state.ts | 5 +- src/daemon/session-store.ts | 108 ++++++++++-- src/daemon/session-teardown.ts | 84 ++++----- 58 files changed, 1506 insertions(+), 668 deletions(-) create mode 100644 packages/capture-kit/src/durable-capture/session-binding.fixtures.ts create mode 100644 src/daemon/__tests__/request-execution-scope-lease-expiry.test.ts create mode 100644 src/daemon/__tests__/screen-recording-session-binding.test.ts create mode 100644 src/daemon/__tests__/session-capture-binding.test.ts create mode 100644 src/daemon/__tests__/session-store-lifetime.test.ts create mode 100644 src/daemon/screen-recording-session-binding.ts create mode 100644 src/daemon/session-capture-binding.ts 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..0ff01c1062 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,67 @@ test('a disposal finish disposes a preserving kind’s material too', async () = envelope: { lifecycle: 'completed', metadata: { phase: 'completed' } }, }); }); + +test.each(['rebuild', 'retire', '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, 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/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/scripts/layering/session-resource-ownership.test.ts b/scripts/layering/session-resource-ownership.test.ts index ffac560315..8a1453e6cb 100644 --- a/scripts/layering/session-resource-ownership.test.ts +++ b/scripts/layering/session-resource-ownership.test.ts @@ -19,11 +19,18 @@ 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 });`, ], [ 'packages/capture-kit/src/capture-admission/audio-probe-session-resource.ts', @@ -39,6 +46,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..323d8af3be 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,12 @@ 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/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/src/__tests__/test-utils/store-factory.ts b/src/__tests__/test-utils/store-factory.ts index 6fccc173af..0c9d222497 100644 --- a/src/__tests__/test-utils/store-factory.ts +++ b/src/__tests__/test-utils/store-factory.ts @@ -1,8 +1,13 @@ 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); +} diff --git a/src/daemon/__tests__/app-log-session-resource.test.ts b/src/daemon/__tests__/app-log-session-resource.test.ts index 84a3add4f2..bf65e67c88 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', @@ -335,6 +490,7 @@ function makeContext( const resourcePath = appLogResourceStore.resolvePath(sessionStore.resolveSessionDir(sessionName)); return { admissionLedger: createAppLogAdmissionLedger(), + ref: sessionStore.lookup(sessionName)!, session, sessionName, sessionStore, diff --git a/src/daemon/__tests__/daemon-runtime-app-log.test.ts b/src/daemon/__tests__/daemon-runtime-app-log.test.ts index baad8d7a7a..6fee6e32a4 100644 --- a/src/daemon/__tests__/daemon-runtime-app-log.test.ts +++ b/src/daemon/__tests__/daemon-runtime-app-log.test.ts @@ -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__/lease-lifecycle.test.ts b/src/daemon/__tests__/lease-lifecycle.test.ts index 8fce339a62..0ef074372c 100644 --- a/src/daemon/__tests__/lease-lifecycle.test.ts +++ b/src/daemon/__tests__/lease-lifecycle.test.ts @@ -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__/perf-capture-session-resource.test.ts b/src/daemon/__tests__/perf-capture-session-resource.test.ts index cd4010c381..7441e03774 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'; @@ -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__/request-execution-scope-lease-expiry.test.ts b/src/daemon/__tests__/request-execution-scope-lease-expiry.test.ts new file mode 100644 index 0000000000..16d48f801d --- /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.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'); +}); + +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.test.ts b/src/daemon/__tests__/request-execution-scope.test.ts index 5da8da19d9..a4f0ce5a32 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'; @@ -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 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__/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__/session-capture-binding.test.ts b/src/daemon/__tests__/session-capture-binding.test.ts new file mode 100644 index 0000000000..1058b0f354 --- /dev/null +++ b/src/daemon/__tests__/session-capture-binding.test.ts @@ -0,0 +1,111 @@ +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!; + store.update(ref, { screenRecording: replacement }); + expect(binding.clear(active)).toBe('resource-changed'); + expect(binding.read()).toBe(replacement); + 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-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-store-lifetime.test.ts b/src/daemon/__tests__/session-store-lifetime.test.ts new file mode 100644 index 0000000000..d0e6a84cf0 --- /dev/null +++ b/src/daemon/__tests__/session-store-lifetime.test.ts @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; +import { makeSession } from '../../__tests__/test-utils/session-factories.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; + +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); +}); 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/handlers/record-runtime.ts b/src/daemon/handlers/record-runtime.ts index 758bd95bc4..12cf18cad1 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, + params.sessionStore.requireCurrent(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,15 +246,13 @@ 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; @@ -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/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-execution-scope.ts b/src/daemon/request-execution-scope.ts index 2fcf80bc7c..4430b38d86 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) { diff --git a/src/daemon/screen-recording-session-binding.ts b/src/daemon/screen-recording-session-binding.ts new file mode 100644 index 0000000000..b5d56813d0 --- /dev/null +++ b/src/daemon/screen-recording-session-binding.ts @@ -0,0 +1,33 @@ +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); + draft.screenRecording = screenRecording; + published = sessionStore.publish(address, draft); + }, + 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/server/daemon-runtime-device-claims.test.ts b/src/daemon/server/daemon-runtime-device-claims.test.ts index 97586767cc..05bfb11e22 100644 --- a/src/daemon/server/daemon-runtime-device-claims.test.ts +++ b/src/daemon/server/daemon-runtime-device-claims.test.ts @@ -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-recording-teardown.test.ts b/src/daemon/server/daemon-runtime-recording-teardown.test.ts index d0d9897465..f4eae37115 100644 --- a/src/daemon/server/daemon-runtime-recording-teardown.test.ts +++ b/src/daemon/server/daemon-runtime-recording-teardown.test.ts @@ -35,7 +35,7 @@ test('daemon shutdown awaits durable recording finalization inside its extended const stderrChunks: string[] = []; const teardown = teardownDaemonSessionForShutdown({ - session, + ref: sessionStore.lookup(session.name)!, sessionStore, stderr: { write: (chunk) => stderrChunks.push(chunk) }, }); @@ -72,7 +72,7 @@ test('daemon shutdown resolves durable recording resources through the effective 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-web-close-teardown.test.ts b/src/daemon/server/daemon-runtime-web-close-teardown.test.ts index dbd971d386..64e8d377de 100644 --- a/src/daemon/server/daemon-runtime-web-close-teardown.test.ts +++ b/src/daemon/server/daemon-runtime-web-close-teardown.test.ts @@ -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) }, @@ -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) }, @@ -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 ce6f247d2d..68631cf9b8 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -35,7 +35,7 @@ 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 { createDaemonIdleReap } from './daemon-idle-reap.ts'; import { createSessionIdleExpiry } from './daemon-session-idle-expiry.ts'; import { resolveSessionIdleExpiryMs } from '../session-idle-expiry.ts'; @@ -133,7 +133,7 @@ async function settleDaemonTeardownStep(params: { * silently swallowed. */ export async function teardownDaemonSessionForShutdown(params: { - session: SessionState; + ref: SessionRef; sessionStore: SessionStore; stateDir?: string; stderr: WritableOutput; @@ -142,7 +142,7 @@ export async function teardownDaemonSessionForShutdown(params: { afterSuccessfulTeardown?: (session: SessionState) => Promise; }): Promise { const { - session, + ref, sessionStore, stateDir, stderr, @@ -150,7 +150,7 @@ export async function teardownDaemonSessionForShutdown(params: { beforeDelete, afterSuccessfulTeardown, } = params; - const sessionName = sessionStore.resolveStoredSessionName(session); + const session = sessionStore.resolveCurrent(ref) ?? ref.session; 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 +160,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 +171,7 @@ export async function teardownDaemonSessionForShutdown(params: { teardown: async () => await teardownSessionResources({ appLog: 'already-settled', - session: sessionAfterAppLog, - sessionName, + ref, sessionStore, stateDir, platformCleanup: platformResourceCleanup, @@ -202,7 +201,7 @@ export async function teardownDaemonSessionForShutdown(params: { sessionStore.finalizeRepairTeardown(session); await beforeDelete?.(session); if (teardownSucceeded) await afterSuccessfulTeardown?.(session); - sessionStore.delete(sessionName); + sessionStore.retire(ref); } export type DaemonRuntimeOptions = { @@ -471,10 +470,11 @@ 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) => @@ -501,7 +501,7 @@ export async function startDaemonRuntime( }; const teardownDaemonSessions = async (): Promise => { - const sessionsToStop = sessionStore.toArray(); + const sessionsToStop = sessionStore.listRefs(); await Promise.all(sessionsToStop.map(teardownDaemonSession)); }; @@ -517,10 +517,11 @@ export async function startDaemonRuntime( session: SessionState, sessionName: string, ): Promise => { + const ref = sessionStore.lookup(sessionName); + if (!ref) return; await teardownSessionResources({ appLog: 'run', - session, - sessionName, + ref, sessionStore, stateDir: baseDir, platformCleanup: platformResourceCleanup, @@ -769,6 +770,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/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-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-teardown-resources.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-teardown-resources.test.ts index d2ffe590f8..3a90103ced 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 @@ -109,14 +109,12 @@ test('daemon resource teardown finalizes recording before lifecycle runner dispo await teardownSessionResources({ appLog: 'already-settled', - session, - sessionName, + ref: sessionStore.lookup(sessionName)!, sessionStore, }); await teardownSessionResources({ appLog: 'already-settled', - session, - sessionName, + ref: sessionStore.lookup(sessionName)!, sessionStore, }); @@ -145,8 +143,7 @@ test('daemon session teardown surfaces a recording finalization failure', async await expect( teardownSessionResources({ appLog: 'already-settled', - session, - sessionName, + ref: sessionStore.lookup(sessionName)!, sessionStore, }), ).rejects.toThrow(/recording: .*failed to stop recording/); @@ -170,8 +167,7 @@ test('daemon session teardown retains recording evidence when finish and forced 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); }); @@ -237,8 +234,7 @@ test('daemon session teardown attempts every resource after an earlier cleanup r await expect( teardownSessionResources({ appLog: 'already-settled', - session, - sessionName, + ref: sessionStore.lookup(sessionName)!, sessionStore, }), ).rejects.toMatchObject({ @@ -286,7 +282,11 @@ test('daemon session teardown closes an open web session immediately, not on age sessionStore.set(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 @@ -309,7 +309,11 @@ test('daemon session teardown surfaces a web close failure through the cleanup-f ); 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..81eb0b82bd 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; @@ -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; + const session = sessionStore.requireCurrent(ref); const attemptCleanup = async ( step: string, run: () => Promise, @@ -86,7 +86,7 @@ export async function runSessionCloseTeardown(params: { }); const configuredRuntimeHints = sessionStore.getRuntimeHints(sessionName); await stopBestEffortSessionResources( - { address: sessionName, session }, + ref, sessionStore, attemptCleanup, params.platformResourceCleanup, @@ -134,21 +134,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.ts b/src/daemon/session-lifecycle/internal/session-close.ts index 889cc5016b..9d2f95dd9b 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'; @@ -193,8 +193,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 +202,7 @@ export async function handleSessionCloseCommands( bindDevice: params.bindDevice, }); } + const session = ref.session; assertTerminalRecordingCloseAllowed(req, session); const app = req.positionals?.[0]; if (req.internal?.closeAppOnly === true && !app) { @@ -246,8 +247,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 +281,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 +293,7 @@ async function runCloseTeardownAndRelease(params: { }): Promise { const { req, - session, - sessionName, + ref, logPath, sessionStore, leaseRegistry, @@ -303,11 +301,11 @@ async function runCloseTeardownAndRelease(params: { lifecycle, clearRuntimeHints, } = params; + const { address: sessionName, session } = ref; const cleanupFailures: SessionCleanupFailure[] = []; const { platformCloseError, saveScriptError, shutdownResult } = await runSessionCloseTeardown({ req, - session, - sessionName, + ref, logPath, sessionStore, lifecycle, 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..f356a1c7a2 100644 --- a/src/daemon/session-observability/internal/__tests__/session-logs.test.ts +++ b/src/daemon/session-observability/internal/__tests__/session-logs.test.ts @@ -269,7 +269,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); 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..ed08e60858 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,6 +201,42 @@ 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' })); 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); @@ -73,7 +76,7 @@ export async function handlePerfRuntimeCommand( if (plan.kind === 'capture-stop') { return recordSuccessfulPerfResponse( params, - await stopPerfCapture(params, session, plan.request), + await stopPerfCapture(bound, session, plan.request), ); } const admitted = await admitRuntimePlan({ @@ -89,7 +92,7 @@ export async function handlePerfRuntimeCommand( } return recordSuccessfulPerfResponse( params, - await executeAdmittedPerfPlan(params, session, admitted), + await executeAdmittedPerfPlan(bound, session, admitted), ); } catch (error) { return { ok: false, error: normalizeError(error) }; @@ -116,7 +119,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 { @@ -183,7 +186,7 @@ async function executeAdmittedPerfPlan( } async function startPerfCapture( - params: PerfRuntimeHandlerParams, + params: PerfRuntimeHandlerParams & { ref: SessionRef }, session: SessionState, runtime: Readonly<{ owner: Parameters[0]['owner']; @@ -214,6 +217,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, @@ -225,9 +230,7 @@ async function startPerfCapture( }); await adoptStartedPerfCapture({ admissionLedger: requirePerfCaptureAdmissionLedger(params), - session, - sessionName: params.sessionName, - sessionStore: params.sessionStore, + binding, device: session.device, owner: runtime.owner, fence, @@ -248,7 +251,7 @@ function requirePerfCaptureAdmissionLedger( } async function stopPerfCapture( - params: PerfRuntimeHandlerParams, + params: PerfRuntimeHandlerParams & { ref: SessionRef }, session: SessionState, request: Extract, ): Promise { @@ -262,14 +265,11 @@ async function stopPerfCapture( } 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 }; } 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-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 e31c66403a..90cea94437 100644 --- a/src/daemon/session-store.ts +++ b/src/daemon/session-store.ts @@ -42,9 +42,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; @@ -62,7 +65,80 @@ export class SessionStore { * nothing here can check the invariant a given field carries. */ get(name: string): SessionState | undefined { - return this.sessions.get(name); + return this.sessions.get(name)?.current; + } + + 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); + } + + private captureRef(address: string, entry: SessionEntry): SessionRef { + return Object.freeze({ address, session: entry.current, lifetime: entry }); } /** @@ -76,9 +152,9 @@ export class SessionStore { // 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); + const entry = this.sessions.get(name); + if (entry) entry.current = session; + else this.publish(name, session); } delete(name: string): boolean { @@ -86,12 +162,12 @@ export class SessionStore { return this.sessions.delete(name); } - 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,21 +176,21 @@ 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 { @@ -298,7 +374,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; } @@ -415,8 +491,8 @@ export class SessionStore { * 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; + for (const [name, entry] of this.sessions) { + if (entry.current === 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' }); } From 6a56b754fa5d5ac9972fad13f7f6fca17263a114 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 4 Oct 2026 04:12:19 +0200 Subject: [PATCH 02/14] fix: finish owned capture cleanup after session retirement --- .../src/durable-capture/adoption.test.ts | 47 +++++++++++++++++++ .../src/durable-capture/adoption.ts | 9 ++-- .../app-log-session-resource.test.ts | 10 ++-- src/daemon/session-store.ts | 1 + 4 files changed, 61 insertions(+), 6 deletions(-) diff --git a/packages/capture-kit/src/durable-capture/adoption.test.ts b/packages/capture-kit/src/durable-capture/adoption.test.ts index f3fb4a521d..a7374adf3b 100644 --- a/packages/capture-kit/src/durable-capture/adoption.test.ts +++ b/packages/capture-kit/src/durable-capture/adoption.test.ts @@ -36,6 +36,52 @@ test('canceled adoption cleans the pending handle before terminalizing its manif expect(context.reportUndurableCleanup).toHaveBeenCalledWith(context.device, { confirmed: true }); }); +test('canceled adoption terminalizes its manifest when the session retires during cleanup', async () => { + const context = makeDurableCaptureContext(); + const start = makeDurableCaptureStartResult(context); + let releaseCleanup!: () => void; + let enterCleanup!: () => void; + const heldCleanup = new Promise((resolve) => { + releaseCleanup = resolve; + }); + const cleanupEntered = new Promise((resolve) => { + enterCleanup = resolve; + }); + start.forceCleanup.mockImplementationOnce(async () => { + enterCleanup(); + await heldCleanup; + return { status: 'cleaned' } as const; + }); + const cancellation = new AppError('CANCELED', 'request canceled'); + const adoption = adoptStartedDurableCapture( + testCaptureDefinition, + { + ...context, + ...start, + throwIfCanceled: () => { + throw cancellation; + }, + }, + context.resourcePath, + ); + const rejected = expect(adoption).rejects.toBe(cancellation); + + await cleanupEntered; + const original = context.sessionStore.lookup(context.sessionName); + expect(context.sessionStore.retire(original)).toBe(true); + const successor = { name: 'successor' }; + context.sessionStore.set(context.sessionName, successor); + releaseCleanup(); + await rejected; + + expect(context.sessionStore.get(context.sessionName)).toBe(successor); + expect(testCaptureStore.read(context.resourcePath)).toMatchObject({ + status: 'decoded', + envelope: { lifecycle: 'completed', metadata: { phase: 'completed' } }, + }); + expect(context.reportUndurableCleanup).toHaveBeenCalledWith(context.device, { confirmed: true }); +}); + test('a failed terminal transition preserves the primary error and reports it unconfirmed', async () => { const context = makeDurableCaptureContext(); const start = makeDurableCaptureStartResult(context, { @@ -89,4 +135,5 @@ test('adoption refuses a retired lifetime even when its address has a vacant suc expect(start.forceCleanup).toHaveBeenCalledOnce(); expect(context.sessionStore.get(context.sessionName)).toBe(successor); expect(testCaptureStore.read(context.resourcePath).status).toBe('missing'); + expect(context.reportUndurableCleanup).toHaveBeenCalledWith(context.device, { confirmed: true }); }); diff --git a/packages/capture-kit/src/durable-capture/adoption.ts b/packages/capture-kit/src/durable-capture/adoption.ts index fef4f1661a..6aa569557d 100644 --- a/packages/capture-kit/src/durable-capture/adoption.ts +++ b/packages/capture-kit/src/durable-capture/adoption.ts @@ -61,9 +61,12 @@ async function recoverFailedAdoption Date: Sun, 4 Oct 2026 04:14:08 +0200 Subject: [PATCH 03/14] test: remove stale capture fixture record --- .../__tests__/durable-capture-resource.fixtures.ts | 1 - 1 file changed, 1 deletion(-) 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 291cd0d543..4f7ec84526 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 @@ -69,7 +69,6 @@ export function makeDurableCaptureContext( replace: (session, appLog) => ({ ...session, appLog, appLogFailure: undefined }), }), admissionLedger: createDurableCaptureAdmissionLedger({ displayName: 'test capture' }), - session, sessionName, sessionStore, device, From 25bce487b9e34f4f6a71d7dcb9bd4cb142361bb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 4 Oct 2026 07:43:12 +0200 Subject: [PATCH 04/14] fix: retain record-only binding publication ownership --- .../screen-recording-session-binding.test.ts | 56 +++++++++++++++++++ .../screen-recording-session-binding.ts | 34 +++++++---- 2 files changed, 80 insertions(+), 10 deletions(-) diff --git a/src/daemon/__tests__/screen-recording-session-binding.test.ts b/src/daemon/__tests__/screen-recording-session-binding.test.ts index 520b6b3452..0f7b21feae 100644 --- a/src/daemon/__tests__/screen-recording-session-binding.test.ts +++ b/src/daemon/__tests__/screen-recording-session-binding.test.ts @@ -58,3 +58,59 @@ test('shutdown refuses draft publication while retaining unconfirmed recording c if (record.status !== 'decoded') throw new Error('Expected recovery evidence'); expect(record.envelope.metadata?.runtimeContractInvalid).toBeUndefined(); }); + +test('a draft binding retains its latest observed recording after retirement', () => { + const store = makeSessionStore(); + const session = makeRecordingSession({ + name: 'draft', + sessionStore: store, + finish: async () => ({ status: 'cleanup-pending', reason: 'cleanup-unconfirmed' }), + }); + const first = session.screenRecording!; + const latest = makeRecordingSession({ + name: 'draft', + sessionStore: store, + finish: async () => ({ status: 'cleanup-pending', reason: 'cleanup-unconfirmed' }), + }).screenRecording!; + const draft = bindRecordOnlyScreenRecording(store, 'draft', { + ...session, + screenRecording: undefined, + }); + draft.binding.adopt(first); + const ref = draft.requireRef(); + store.update(ref, { screenRecording: latest }); + expect(draft.binding.read()).toBe(latest); + expect(store.retire(ref)).toBe(true); + expect(draft.binding.read()).toBe(latest); + expect(draft.binding.clear(latest)).toBe('retired'); +}); + +test('a published draft cannot create another lifetime after retirement', () => { + const store = makeSessionStore(); + const session = makeRecordingSession({ + name: 'draft', + sessionStore: store, + finish: async () => ({ status: 'cleanup-pending', reason: 'cleanup-unconfirmed' }), + }); + const recording = session.screenRecording!; + const draft = bindRecordOnlyScreenRecording(store, 'draft', { + ...session, + screenRecording: undefined, + }); + draft.binding.adopt(recording); + const ref = draft.requireRef(); + expect(store.retire(ref)).toBe(true); + expect(draft.binding.canPersist()).toBe(false); + expect(() => draft.binding.assertAdoptable()).toThrowError( + expect.objectContaining({ + details: expect.objectContaining({ reason: 'session_lifetime_ended' }), + }), + ); + expect(() => draft.binding.adopt(recording)).toThrowError( + expect.objectContaining({ + details: expect.objectContaining({ reason: 'session_lifetime_ended' }), + }), + ); + expect(store.lookup('draft')).toBeUndefined(); + expect(draft.requireRef()).toBe(ref); +}); diff --git a/src/daemon/screen-recording-session-binding.ts b/src/daemon/screen-recording-session-binding.ts index b5d56813d0..bbd799e06e 100644 --- a/src/daemon/screen-recording-session-binding.ts +++ b/src/daemon/screen-recording-session-binding.ts @@ -1,3 +1,4 @@ +import { AppError } from '@agent-device/kernel/errors'; import { bindSessionScreenRecording } from './session-capture-binding.ts'; import type { SessionRef } from './session-state.ts'; import type { SessionStore } from './session-store.ts'; @@ -7,27 +8,40 @@ export function bindRecordOnlyScreenRecording( address: string, draft: SessionRef['session'], ) { - let published: SessionRef | undefined; + let published: + | Readonly<{ + ref: SessionRef; + binding: ReturnType; + }> + | undefined; + const assertAdoptable = (): void => { + if (published) { + sessionStore.requireCurrent(published.ref); + throw new AppError('COMMAND_FAILED', 'Recording draft has already been published', { + reason: 'session_resource_changed', + session: address, + }); + } + sessionStore.assertPublishable(address); + }; const binding: ReturnType = Object.freeze({ address, sessionDir: sessionStore.resolveSessionDir(address), - read: () => - published ? bindSessionScreenRecording(sessionStore, published).read() : undefined, - assertAdoptable: () => sessionStore.assertPublishable(address), + read: () => published?.binding.read(), + assertAdoptable, canPersist: () => !published && sessionStore.lookup(address) === undefined, adopt: (screenRecording) => { - sessionStore.assertPublishable(address); - draft.screenRecording = screenRecording; - published = sessionStore.publish(address, draft); + assertAdoptable(); + const ref = sessionStore.publish(address, { ...draft, screenRecording }); + published = Object.freeze({ ref, binding: bindSessionScreenRecording(sessionStore, ref) }); }, - clear: (expected) => - published ? bindSessionScreenRecording(sessionStore, published).clear(expected) : 'retired', + clear: (expected) => published?.binding.clear(expected) ?? 'retired', }); return Object.freeze({ binding, requireRef: (): SessionRef => { if (!published) throw new TypeError('Screen recording did not publish its session'); - return published; + return published.ref; }, }); } From 86f4ac8ebb64e102f1c71b3cf86cacfc07515c6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 4 Oct 2026 07:46:45 +0200 Subject: [PATCH 05/14] refactor: publish recording drafts through the resource owner --- src/daemon/screen-recording-session-binding.ts | 7 +++++-- src/daemon/session-capture-binding.ts | 9 +++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/daemon/screen-recording-session-binding.ts b/src/daemon/screen-recording-session-binding.ts index bbd799e06e..efef23e1d0 100644 --- a/src/daemon/screen-recording-session-binding.ts +++ b/src/daemon/screen-recording-session-binding.ts @@ -1,5 +1,8 @@ import { AppError } from '@agent-device/kernel/errors'; -import { bindSessionScreenRecording } from './session-capture-binding.ts'; +import { + bindSessionScreenRecording, + publishRecordOnlyScreenRecording, +} from './session-capture-binding.ts'; import type { SessionRef } from './session-state.ts'; import type { SessionStore } from './session-store.ts'; @@ -32,7 +35,7 @@ export function bindRecordOnlyScreenRecording( canPersist: () => !published && sessionStore.lookup(address) === undefined, adopt: (screenRecording) => { assertAdoptable(); - const ref = sessionStore.publish(address, { ...draft, screenRecording }); + const ref = publishRecordOnlyScreenRecording(sessionStore, address, draft, screenRecording); published = Object.freeze({ ref, binding: bindSessionScreenRecording(sessionStore, ref) }); }, clear: (expected) => published?.binding.clear(expected) ?? 'retired', diff --git a/src/daemon/session-capture-binding.ts b/src/daemon/session-capture-binding.ts index 97555f0b93..2000fb6833 100644 --- a/src/daemon/session-capture-binding.ts +++ b/src/daemon/session-capture-binding.ts @@ -85,3 +85,12 @@ export function bindSessionScreenRecording(sessionStore: SessionStore, ref: Sess }, }); } + +export function publishRecordOnlyScreenRecording( + sessionStore: SessionStore, + address: string, + draft: SessionState, + screenRecording: NonNullable, +): SessionRef { + return sessionStore.publish(address, { ...draft, screenRecording }); +} From 324a86f994ac482b94d78077efe5413198a90c0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 4 Oct 2026 08:02:51 +0200 Subject: [PATCH 06/14] refactor: colocate recording draft publication with capture bindings --- .../screen-recording-session-binding.test.ts | 116 --------------- .../__tests__/session-capture-binding.test.ts | 136 +++++++++++++++++- src/daemon/handlers/record-runtime.ts | 2 +- .../screen-recording-session-binding.ts | 50 ------- src/daemon/session-capture-binding.ts | 52 ++++++- 5 files changed, 182 insertions(+), 174 deletions(-) delete mode 100644 src/daemon/__tests__/screen-recording-session-binding.test.ts delete mode 100644 src/daemon/screen-recording-session-binding.ts diff --git a/src/daemon/__tests__/screen-recording-session-binding.test.ts b/src/daemon/__tests__/screen-recording-session-binding.test.ts deleted file mode 100644 index 0f7b21feae..0000000000 --- a/src/daemon/__tests__/screen-recording-session-binding.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -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(); -}); - -test('a draft binding retains its latest observed recording after retirement', () => { - const store = makeSessionStore(); - const session = makeRecordingSession({ - name: 'draft', - sessionStore: store, - finish: async () => ({ status: 'cleanup-pending', reason: 'cleanup-unconfirmed' }), - }); - const first = session.screenRecording!; - const latest = makeRecordingSession({ - name: 'draft', - sessionStore: store, - finish: async () => ({ status: 'cleanup-pending', reason: 'cleanup-unconfirmed' }), - }).screenRecording!; - const draft = bindRecordOnlyScreenRecording(store, 'draft', { - ...session, - screenRecording: undefined, - }); - draft.binding.adopt(first); - const ref = draft.requireRef(); - store.update(ref, { screenRecording: latest }); - expect(draft.binding.read()).toBe(latest); - expect(store.retire(ref)).toBe(true); - expect(draft.binding.read()).toBe(latest); - expect(draft.binding.clear(latest)).toBe('retired'); -}); - -test('a published draft cannot create another lifetime after retirement', () => { - const store = makeSessionStore(); - const session = makeRecordingSession({ - name: 'draft', - sessionStore: store, - finish: async () => ({ status: 'cleanup-pending', reason: 'cleanup-unconfirmed' }), - }); - const recording = session.screenRecording!; - const draft = bindRecordOnlyScreenRecording(store, 'draft', { - ...session, - screenRecording: undefined, - }); - draft.binding.adopt(recording); - const ref = draft.requireRef(); - expect(store.retire(ref)).toBe(true); - expect(draft.binding.canPersist()).toBe(false); - expect(() => draft.binding.assertAdoptable()).toThrowError( - expect.objectContaining({ - details: expect.objectContaining({ reason: 'session_lifetime_ended' }), - }), - ); - expect(() => draft.binding.adopt(recording)).toThrowError( - expect.objectContaining({ - details: expect.objectContaining({ reason: 'session_lifetime_ended' }), - }), - ); - expect(store.lookup('draft')).toBeUndefined(); - expect(draft.requireRef()).toBe(ref); -}); diff --git a/src/daemon/__tests__/session-capture-binding.test.ts b/src/daemon/__tests__/session-capture-binding.test.ts index 1058b0f354..2ad3ae5f78 100644 --- a/src/daemon/__tests__/session-capture-binding.test.ts +++ b/src/daemon/__tests__/session-capture-binding.test.ts @@ -1,7 +1,18 @@ -import { expect, test } from 'vitest'; +import { expect, test, vi } 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'; +import { + bindSessionScreenRecording, + bindRecordOnlyScreenRecording, +} from '../session-capture-binding.ts'; + +import { createScreenRecordingLiveHandle } from '@agent-device/capture-kit'; +import { PendingTransferGuard } from '@agent-device/contracts/async-lifecycle'; +import { createScreenRecordingAdmissionLedger } from '@agent-device/capture-kit/screen-recording-admission-ledger'; +import { + adoptStartedScreenRecording, + screenRecordingDurableResource, +} from '@agent-device/capture-kit/screen-recording-session-resource'; test('adoption owns a vacant slot and retains its adopted handle after retirement', () => { const store = makeSessionStore(); @@ -109,3 +120,124 @@ test('a retired binding retains its old resource but cannot write into the next ); expect(store.requireCurrent(successor).screenRecording).toBe(active); }); + +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(); +}); + +test('a draft binding retains its latest observed recording after retirement', () => { + const store = makeSessionStore(); + const session = makeRecordingSession({ + name: 'draft', + sessionStore: store, + finish: async () => ({ status: 'cleanup-pending', reason: 'cleanup-unconfirmed' }), + }); + const first = session.screenRecording!; + const latest = makeRecordingSession({ + name: 'draft', + sessionStore: store, + finish: async () => ({ status: 'cleanup-pending', reason: 'cleanup-unconfirmed' }), + }).screenRecording!; + const draft = bindRecordOnlyScreenRecording(store, 'draft', { + ...session, + screenRecording: undefined, + }); + draft.binding.adopt(first); + const ref = draft.requireRef(); + store.update(ref, { screenRecording: latest }); + expect(draft.binding.read()).toBe(latest); + expect(store.retire(ref)).toBe(true); + expect(draft.binding.read()).toBe(latest); + expect(draft.binding.clear(latest)).toBe('retired'); +}); + +test('a published draft cannot create another lifetime after retirement', () => { + const store = makeSessionStore(); + const session = makeRecordingSession({ + name: 'draft', + sessionStore: store, + finish: async () => ({ status: 'cleanup-pending', reason: 'cleanup-unconfirmed' }), + }); + const recording = session.screenRecording!; + const draft = bindRecordOnlyScreenRecording(store, 'draft', { + ...session, + screenRecording: undefined, + }); + draft.binding.adopt(recording); + const ref = draft.requireRef(); + expect(store.retire(ref)).toBe(true); + expect(draft.binding.canPersist()).toBe(false); + expect(() => draft.binding.assertAdoptable()).toThrowError( + expect.objectContaining({ + details: expect.objectContaining({ reason: 'session_lifetime_ended' }), + }), + ); + expect(() => draft.binding.adopt(recording)).toThrowError( + expect.objectContaining({ + details: expect.objectContaining({ reason: 'session_lifetime_ended' }), + }), + ); + expect(store.lookup('draft')).toBeUndefined(); + expect(draft.requireRef()).toBe(ref); +}); + +test('an occupied recording draft is refused before publication', () => { + const store = makeSessionStore(); + const session = makeRecordingSession({ + name: 'occupied-draft', + sessionStore: store, + finish: async () => ({ status: 'cleanup-pending', reason: 'cleanup-unconfirmed' }), + }); + const draft = bindRecordOnlyScreenRecording(store, 'occupied-draft', session); + expect(() => draft.binding.adopt(session.screenRecording!)).toThrowError( + expect.objectContaining({ + details: expect.objectContaining({ reason: 'session_resource_changed' }), + }), + ); + expect(store.lookup('occupied-draft')).toBeUndefined(); +}); diff --git a/src/daemon/handlers/record-runtime.ts b/src/daemon/handlers/record-runtime.ts index 12cf18cad1..f440ee11cf 100644 --- a/src/daemon/handlers/record-runtime.ts +++ b/src/daemon/handlers/record-runtime.ts @@ -32,7 +32,7 @@ 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 { SessionRef, SessionState } from '../session-state.ts'; -import { bindRecordOnlyScreenRecording } from '../screen-recording-session-binding.ts'; +import { bindRecordOnlyScreenRecording } from '../session-capture-binding.ts'; import { recordSessionAction } from '../session-action-recorder.ts'; import { missingAppSessionResponse, diff --git a/src/daemon/screen-recording-session-binding.ts b/src/daemon/screen-recording-session-binding.ts deleted file mode 100644 index efef23e1d0..0000000000 --- a/src/daemon/screen-recording-session-binding.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { AppError } from '@agent-device/kernel/errors'; -import { - bindSessionScreenRecording, - publishRecordOnlyScreenRecording, -} 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: - | Readonly<{ - ref: SessionRef; - binding: ReturnType; - }> - | undefined; - const assertAdoptable = (): void => { - if (published) { - sessionStore.requireCurrent(published.ref); - throw new AppError('COMMAND_FAILED', 'Recording draft has already been published', { - reason: 'session_resource_changed', - session: address, - }); - } - sessionStore.assertPublishable(address); - }; - const binding: ReturnType = Object.freeze({ - address, - sessionDir: sessionStore.resolveSessionDir(address), - read: () => published?.binding.read(), - assertAdoptable, - canPersist: () => !published && sessionStore.lookup(address) === undefined, - adopt: (screenRecording) => { - assertAdoptable(); - const ref = publishRecordOnlyScreenRecording(sessionStore, address, draft, screenRecording); - published = Object.freeze({ ref, binding: bindSessionScreenRecording(sessionStore, ref) }); - }, - clear: (expected) => published?.binding.clear(expected) ?? 'retired', - }); - return Object.freeze({ - binding, - requireRef: (): SessionRef => { - if (!published) throw new TypeError('Screen recording did not publish its session'); - return published.ref; - }, - }); -} diff --git a/src/daemon/session-capture-binding.ts b/src/daemon/session-capture-binding.ts index 2000fb6833..bf509c92d4 100644 --- a/src/daemon/session-capture-binding.ts +++ b/src/daemon/session-capture-binding.ts @@ -86,11 +86,53 @@ export function bindSessionScreenRecording(sessionStore: SessionStore, ref: Sess }); } -export function publishRecordOnlyScreenRecording( +export function bindRecordOnlyScreenRecording( sessionStore: SessionStore, address: string, - draft: SessionState, - screenRecording: NonNullable, -): SessionRef { - return sessionStore.publish(address, { ...draft, screenRecording }); + draft: SessionRef['session'], +) { + let published: + | Readonly<{ + ref: SessionRef; + binding: ReturnType; + }> + | undefined; + const assertAdoptable = (): void => { + if (published) { + sessionStore.requireCurrent(published.ref); + throw new AppError('COMMAND_FAILED', 'Recording draft has already been published', { + reason: 'session_resource_changed', + session: address, + }); + } + sessionStore.assertPublishable(address); + }; + const binding: ReturnType = Object.freeze({ + address, + sessionDir: sessionStore.resolveSessionDir(address), + read: () => published?.binding.read(), + assertAdoptable, + canPersist: () => !published && sessionStore.lookup(address) === undefined, + adopt: (screenRecording) => { + assertAdoptable(); + if (draft.screenRecording) { + throw new AppError('COMMAND_FAILED', 'Recording draft already owns a resource', { + reason: 'session_resource_changed', + session: address, + }); + } + const ref = sessionStore.publish(address, draft); + const binding = bindSessionScreenRecording(sessionStore, ref); + binding.adopt(screenRecording); + published = Object.freeze({ ref, binding }); + }, + clear: (expected) => published?.binding.clear(expected) ?? 'retired', + }); + return Object.freeze({ + binding, + requireRef: (): SessionRef => { + if (!published) throw new TypeError('Screen recording did not publish its session'); + return published.ref; + }, + }); } From bbbacb1614abe2ee92a22e518c9c8aa8ee61e088 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 4 Oct 2026 08:09:18 +0200 Subject: [PATCH 07/14] chore: merge recording binding imports --- src/daemon/handlers/record-runtime.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/daemon/handlers/record-runtime.ts b/src/daemon/handlers/record-runtime.ts index f440ee11cf..5a0a11d51b 100644 --- a/src/daemon/handlers/record-runtime.ts +++ b/src/daemon/handlers/record-runtime.ts @@ -4,7 +4,10 @@ import type { ScreenRecordingCompletion, ScreenRecordingStartInput, } from '@agent-device/contracts/screen-recording-runtime'; -import { bindSessionScreenRecording } from '../session-capture-binding.ts'; +import { + bindSessionScreenRecording, + bindRecordOnlyScreenRecording, +} from '../session-capture-binding.ts'; import { resolveScreenRecordingRuntimePlan, screenRecordingAdmissionUse, @@ -32,7 +35,6 @@ 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 { SessionRef, SessionState } from '../session-state.ts'; -import { bindRecordOnlyScreenRecording } from '../session-capture-binding.ts'; import { recordSessionAction } from '../session-action-recorder.ts'; import { missingAppSessionResponse, From 9efe1578c5d4c7198255e46ddaca4623a0143e7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 4 Oct 2026 08:29:33 +0200 Subject: [PATCH 08/14] fix: fence shutdown admission and resource completion by lifetime --- src/daemon/app-log-session-resource.ts | 16 +++- .../record-runtime-stop-recovery.test.ts | 16 ++++ src/daemon/handlers/record-runtime.ts | 22 +++-- .../daemon-runtime-lifecycle-shutdown.test.ts | 94 +++++++++++++++++++ src/daemon/server/daemon-runtime.ts | 2 +- .../internal/__tests__/session-logs.test.ts | 32 +++++++ .../__tests__/session-perf-runtime.test.ts | 21 +++++ .../internal/session-observability.ts | 21 +---- .../internal/session-perf-runtime.ts | 8 +- 9 files changed, 202 insertions(+), 30 deletions(-) diff --git a/src/daemon/app-log-session-resource.ts b/src/daemon/app-log-session-resource.ts index 1099c5811e..7645ea7128 100644 --- a/src/daemon/app-log-session-resource.ts +++ b/src/daemon/app-log-session-resource.ts @@ -7,7 +7,7 @@ import type { RuntimeOwnerRef, } from '@agent-device/contracts/platform-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { normalizeError } from '@agent-device/kernel/errors'; +import { AppError, normalizeError } from '@agent-device/kernel/errors'; import type { AppLogAdmissionLedger } from './app-log-admission-ledger.ts'; import { createDurableCaptureResource, @@ -16,6 +16,7 @@ import { import { appLogResourceStore } from './app-log-resource-store.ts'; import type { SessionStore } from './session-store.ts'; import type { SessionRef, SessionState } from './session-state.ts'; +import { clearAppLogFiles } from './app-log.ts'; import { bindSessionCapture } from './session-capture-binding.ts'; export type AppLogSessionSnapshot = Readonly<{ @@ -145,3 +146,16 @@ export function bindSessionAppLog(sessionStore: SessionStore, ref: SessionRef) { }, }); } + +export function clearStoppedSessionAppLog(params: { ref: SessionRef; sessionStore: SessionStore }) { + const session = params.sessionStore.requireCurrent(params.ref); + if (session.appLog) { + throw new AppError( + 'INVALID_ARGS', + 'logs clear requires logs to be stopped first; run logs stop', + ); + } + const cleared = clearAppLogFiles(params.sessionStore.resolveAppLogPath(params.ref.address)); + clearSessionAppLogFailure(params); + return cleared; +} 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..041ce1de26 100644 --- a/src/daemon/handlers/__tests__/record-runtime-stop-recovery.test.ts +++ b/src/daemon/handlers/__tests__/record-runtime-stop-recovery.test.ts @@ -205,3 +205,19 @@ function writeRecording(prefix: string): string { fs.writeFileSync(outPath, 'mp4'); return outPath; } + +test('live record stop writes its action under the captured scoped address after rebuilding the record', async () => { + const harness = makeRecordRuntimeHarness('record-runtime-scoped-stop-', { + sessionName: 'cwd:0123456789abcdef:default', + }); + harness.session.name = 'default'; + await expect(harness.run(['start', 'capture.mp4'])).resolves.toMatchObject({ ok: true }); + await expect(harness.run(['stop'])).resolves.toMatchObject({ ok: true }); + await harness.sessionStore.flushEvents(); + expect( + harness.sessionStore + .readEvents(harness.sessionName) + .events.filter((event) => event.kind === 'action.recorded'), + ).toHaveLength(2); + expect(harness.sessionStore.readEvents('default').events).toEqual([]); +}); diff --git a/src/daemon/handlers/record-runtime.ts b/src/daemon/handlers/record-runtime.ts index 5a0a11d51b..9771b334d1 100644 --- a/src/daemon/handlers/record-runtime.ts +++ b/src/daemon/handlers/record-runtime.ts @@ -260,14 +260,20 @@ async function stopRecording( const completion = stopped.completion; const response = buildRecordingStopResponse(completion); if (stopped.recordsSessionAction) { - recordSessionAction(params.sessionStore, session, params.req, params.req.command, { - action: 'stop', - outPath: completion.outPath, - ...(completion.clientOutPath - ? { requestedFileName: path.basename(completion.clientOutPath) } - : {}), - showTouches: completion.showTouches, - }); + recordSessionAction( + params.sessionStore, + ref ? params.sessionStore.resolveCurrent(ref) : undefined, + params.req, + params.req.command, + { + action: 'stop', + outPath: completion.outPath, + ...(completion.clientOutPath + ? { requestedFileName: path.basename(completion.clientOutPath) } + : {}), + showTouches: completion.showTouches, + }, + ); } if (session.recordOnlySession && ref) params.sessionStore.retire(ref); return response; diff --git a/src/daemon/server/daemon-runtime-lifecycle-shutdown.test.ts b/src/daemon/server/daemon-runtime-lifecycle-shutdown.test.ts index 1b39bd2745..9ddc48804b 100644 --- a/src/daemon/server/daemon-runtime-lifecycle-shutdown.test.ts +++ b/src/daemon/server/daemon-runtime-lifecycle-shutdown.test.ts @@ -46,10 +46,50 @@ vi.mock('../../provider-device-runtimes.ts', () => ({ createDaemonProviderRuntimeComposition: async () => ({ runtimes: [], platformModules: [] }), })); +const shutdownProbe = vi.hoisted(() => ({ + store: undefined as import('../session-store.ts').SessionStore | undefined, + drain: undefined as (() => Promise) | undefined, + finalize: vi.fn(async () => {}), +})); +vi.mock('../session-store.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + SessionStore: class extends actual.SessionStore { + constructor(...args: ConstructorParameters) { + super(...args); + shutdownProbe.store = this; + } + }, + }; +}); +vi.mock('./server-shutdown.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + closeDaemonServers: async (...args: Parameters) => { + await actual.closeDaemonServers(...args); + await shutdownProbe.drain?.(); + }, + }; +}); +vi.mock('../application-lifecycle-recovery.ts', async (importOriginal) => ({ + ...(await importOriginal()), + finalizeDaemonSessionApplicationLifecycle: shutdownProbe.finalize, +})); +import { acquireDeviceClaim } from '../device/device-claims.ts'; +import { resolveDeviceClaimPath } from '../device/device-claim-paths.ts'; +import { ANDROID_EMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; +import { + isolatedDeviceClaimStores, + retainOrphanedDeviceClaims, +} from '../../__tests__/test-utils/device-claim-store.ts'; import { startDaemonRuntime } from './daemon-runtime.ts'; afterEach(() => { lifecycleEvents.length = 0; + shutdownProbe.drain = undefined; + shutdownProbe.finalize.mockReset(); }); test('daemon shutdown detaches before session teardown and force-finalizes only after gateway resources', async () => { @@ -108,3 +148,57 @@ test('a SIGTERM shutdown gives the handoff a diagnostics scope to write its reas fs.rmSync(stateDir, { recursive: true, force: true }); } }); + +const claimStores = isolatedDeviceClaimStores('daemon-drain-admission-'); + +test('shutdown includes drain publications, releases their claims and refuses post-snapshot publication', async () => { + const { stateDir } = claimStores(); + const runtime = await startDaemonRuntime({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + }, + exit: () => {}, + registerProcessHandlers: false, + stderr: { write: () => {} }, + stdout: { write: () => {} }, + }); + expect(runtime).not.toBeNull(); + const store = shutdownProbe.store!; + const acquired = await acquireDeviceClaim({ + device: ANDROID_EMULATOR, + session: 'draining', + workspace: stateDir, + stateDir, + reconcileOrphanedDeviceClaim: retainOrphanedDeviceClaims, + }); + if (acquired.status !== 'acquired') throw new Error('Expected acquired claim'); + const session = { + name: 'draining', + device: ANDROID_EMULATOR, + createdAt: Date.now(), + actions: [], + deviceClaim: acquired.ownership, + }; + shutdownProbe.drain = async () => { + store.publish('draining', session); + }; + shutdownProbe.finalize.mockImplementationOnce(async () => { + expect(() => store.publish('late', { ...session, name: 'late' })).toThrowError( + expect.objectContaining({ + details: expect.objectContaining({ reason: 'daemon_shutting_down' }), + }), + ); + }); + try { + await runtime!.shutdown(); + expect(shutdownProbe.finalize).toHaveBeenCalledOnce(); + expect(store.lookup('draining')).toBeUndefined(); + expect(store.lookup('late')).toBeUndefined(); + expect(fs.existsSync(resolveDeviceClaimPath(acquired.ownership.deviceKey))).toBe(false); + } finally { + await runtime!.shutdown(); + } +}); diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index 68631cf9b8..e2bd61ea3e 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -501,6 +501,7 @@ export async function startDaemonRuntime( }; const teardownDaemonSessions = async (): Promise => { + sessionStore.closeAdmission(); const sessionsToStop = sessionStore.listRefs(); await Promise.all(sessionsToStop.map(teardownDaemonSession)); }; @@ -770,7 +771,6 @@ 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/session-observability/internal/__tests__/session-logs.test.ts b/src/daemon/session-observability/internal/__tests__/session-logs.test.ts index f356a1c7a2..bb675c4384 100644 --- a/src/daemon/session-observability/internal/__tests__/session-logs.test.ts +++ b/src/daemon/session-observability/internal/__tests__/session-logs.test.ts @@ -1,3 +1,4 @@ +import fs from 'node:fs'; import { beforeEach, expect, test, vi } from 'vitest'; import type { AppLogRuntimeOperations } from '@agent-device/contracts/app-log-runtime'; import { applicationLifecycleOperationFacts } from '@agent-device/contracts/application-lifecycle-runtime'; @@ -471,3 +472,34 @@ const unavailableRecording = Object.freeze({ available: false as const, reason: 'owner-capability-missing' as const, }); + +test('logs clear --restart leaves successor files intact after finish outlives its lifetime', async () => { + const { sessionStore, sessionName } = openSession(); + await expectStarted(await runLogs(sessionStore, sessionName, ['start'], {}, runtime.bindDevice)); + const ref = sessionStore.lookup(sessionName)!; + const logPath = sessionStore.resolveAppLogPath(sessionName); + runtime.finish.mockImplementationOnce(async () => { + sessionStore.retire(ref); + sessionStore.publish(sessionName, { ...ref.session, appLog: undefined }); + fs.writeFileSync(logPath, 'successor log'); + fs.writeFileSync(`${logPath}.1`, 'successor rotated log'); + return { + status: 'completed', + result: { backend: 'ios-simulator', outputPath: logPath, completedAt: Date.now() }, + }; + }); + const response = await runLogs( + sessionStore, + sessionName, + ['clear'], + { restart: true }, + runtime.bindDevice, + ); + expect(response).toMatchObject({ + ok: false, + error: { details: { reason: 'session_lifetime_ended' } }, + }); + expect(fs.readFileSync(logPath, 'utf8')).toBe('successor log'); + expect(fs.readFileSync(`${logPath}.1`, 'utf8')).toBe('successor rotated log'); + expect(runtime.start).toHaveBeenCalledOnce(); +}); 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 ed08e60858..365f668842 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 @@ -306,3 +306,24 @@ function createPerfRuntime( }; return { inspectFacts, bindDevice, uses }; } + +test('a perf result does not record into a successor occupying the same address', async () => { + const sessionStore = makeStore(); + const ref = sessionStore.lookup('android')!; + const perfFrames = vi.fn(async () => { + sessionStore.retire(ref); + sessionStore.publish('android', makeAndroidSession('successor')); + return { metric: { available: true, fps: 59.8 }, sampling: { method: 'fixture' } }; + }); + const runtime = createPerfRuntime({ perfFrames }); + const response = await handleSessionObservabilityCommands({ + req: { token: 't', session: 'android', command: 'perf', positionals: ['frames'] }, + sessionName: 'android', + sessionStore, + inspectFacts: runtime.inspectFacts, + bindDevice: runtime.bindDevice, + perfCaptureAdmissionLedger: createPerfCaptureAdmissionLedger(), + }); + assert.equal(response?.ok, true, JSON.stringify(response)); + assert.deepEqual(sessionStore.get('android')!.actions, []); +}); diff --git a/src/daemon/session-observability/internal/session-observability.ts b/src/daemon/session-observability/internal/session-observability.ts index 8b4302a297..8e54d0205c 100644 --- a/src/daemon/session-observability/internal/session-observability.ts +++ b/src/daemon/session-observability/internal/session-observability.ts @@ -11,7 +11,7 @@ import { import type { RuntimeOwnerRef } from '@agent-device/contracts/platform-runtime'; import { uniqueStrings } from '@agent-device/kernel/collections'; import { AppError, normalizeError } from '@agent-device/kernel/errors'; -import { appendAppLogMarker, clearAppLogFiles, getAppLogPathMetadata } from '../../app-log.ts'; +import { appendAppLogMarker, getAppLogPathMetadata } from '../../app-log.ts'; import type { AppLogAdmissionLedger } from '../../app-log-admission-ledger.ts'; import { type AudioProbeAdmissionLedger } from '@agent-device/capture-kit/audio-probe-admission-ledger'; import { type PerfCaptureAdmissionLedger } from '@agent-device/capture-kit/perf-capture-admission-ledger'; @@ -19,7 +19,7 @@ import { appLogResourceStore } from '../../app-log-resource-store.ts'; import { adoptStartedSessionAppLog, bindSessionAppLog, - clearSessionAppLogFailure, + clearStoppedSessionAppLog, finishSessionAppLog, inspectSessionAppLog, recordSessionAppLogFailure, @@ -299,17 +299,7 @@ function handleLogsMark( } function handleLogsClear(params: LogsHandlerParams): DaemonResponse { - const { session, sessionName, sessionStore } = params; - if (session.appLog) { - return errorResponse( - 'INVALID_ARGS', - 'logs clear requires logs to be stopped first; run logs stop', - ); - } - const logPath = sessionStore.resolveAppLogPath(sessionName); - const cleared = clearAppLogFiles(logPath); - clearSessionAppLogFailure({ ref: params.ref, sessionStore }); - return { ok: true, data: cleared }; + return { ok: true, data: clearStoppedSessionAppLog(params) }; } async function handleLogsClearRestart( @@ -318,7 +308,7 @@ async function handleLogsClearRestart( owner: RuntimeOwnerRef, start: AppLogRuntimeOperations['appLogStart'], ): Promise { - const { session, sessionName, sessionStore } = params; + const { session, sessionStore } = params; if (session.appLog) { // The stream is replaced and its files cleared behind it: nobody captures that completion, and // an open record left here would refuse the start this path exists to serve. @@ -328,8 +318,7 @@ async function handleLogsClearRestart( sessionStore, }); } - const logPath = sessionStore.resolveAppLogPath(sessionName); - const cleared = clearAppLogFiles(logPath); + const cleared = clearStoppedSessionAppLog(params); const started = await startSessionAppLog(params, appBundleId, start, owner); return started.ok ? { ok: true, data: { ...cleared, restarted: true } } : started; } diff --git a/src/daemon/session-observability/internal/session-perf-runtime.ts b/src/daemon/session-observability/internal/session-perf-runtime.ts index f8761f6231..0dc3edb51a 100644 --- a/src/daemon/session-observability/internal/session-perf-runtime.ts +++ b/src/daemon/session-observability/internal/session-perf-runtime.ts @@ -75,7 +75,7 @@ export async function handlePerfRuntimeCommand( const plan = resolvePerfRuntimePlan(request); if (plan.kind === 'capture-stop') { return recordSuccessfulPerfResponse( - params, + bound, await stopPerfCapture(bound, session, plan.request), ); } @@ -91,7 +91,7 @@ export async function handlePerfRuntimeCommand( ); } return recordSuccessfulPerfResponse( - params, + bound, await executeAdmittedPerfPlan(bound, session, admitted), ); } catch (error) { @@ -100,11 +100,11 @@ 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); + const session = params.sessionStore.resolveCurrent(params.ref); recordSessionAction( params.sessionStore, session, From a4d44017ba5905c09325cd390de66c9ea7198999 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 4 Oct 2026 08:29:33 +0200 Subject: [PATCH 09/14] chore(gates): cover recording in resource policy retirement guidance --- scripts/layering/session-resource-ownership.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/layering/session-resource-ownership.ts b/scripts/layering/session-resource-ownership.ts index 323d8af3be..f9274f53e8 100644 --- a/scripts/layering/session-resource-ownership.ts +++ b/scripts/layering/session-resource-ownership.ts @@ -6,7 +6,7 @@ // the runtime-command migration; 4454aef139 (#2092) removed what it retired. // Cost: 115 LOC (57 rule + 58 test). // Kill criterion: none enforced today; retire only by maintainer decision that per-owner write -// authority over appLog/appLogFailure/audioProbe/perfCapture no longer matters. The fields +// authority over appLog/appLogFailure/audioProbe/perfCapture/screenRecording no longer matters. The fields // are plain mutable properties on the shared session record, so an outside write type-checks. import { parseSync } from 'oxc-parser'; From 6af8dd2a93c9a1003d0b84a46edf127ca71889a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 4 Oct 2026 08:46:17 +0200 Subject: [PATCH 10/14] refactor: share capture binding ownership with transition controls --- .../capture-kit/src/durable-capture/index.ts | 2 + .../session-binding.fixtures.ts | 47 ++++----------- .../durable-capture/session-binding.test.ts | 37 ++++++++++++ .../src/durable-capture/session-binding.ts | 59 +++++++++++++++++++ src/daemon/session-capture-binding.ts | 47 +++------------ 5 files changed, 115 insertions(+), 77 deletions(-) create mode 100644 packages/capture-kit/src/durable-capture/session-binding.test.ts create mode 100644 packages/capture-kit/src/durable-capture/session-binding.ts diff --git a/packages/capture-kit/src/durable-capture/index.ts b/packages/capture-kit/src/durable-capture/index.ts index 1d8f84b174..58dbe4c62c 100644 --- a/packages/capture-kit/src/durable-capture/index.ts +++ b/packages/capture-kit/src/durable-capture/index.ts @@ -23,3 +23,5 @@ export type { } from './recovery.ts'; export type { DurableCaptureRecoveryControl } from './recovery-authority.ts'; export type { DurableCaptureResourceRecord, DurableCaptureResourceStore } from './store.ts'; + +export { createDurableCaptureSessionBinding } from './session-binding.ts'; diff --git a/packages/capture-kit/src/durable-capture/session-binding.fixtures.ts b/packages/capture-kit/src/durable-capture/session-binding.fixtures.ts index e84740ee55..5598a96464 100644 --- a/packages/capture-kit/src/durable-capture/session-binding.fixtures.ts +++ b/packages/capture-kit/src/durable-capture/session-binding.fixtures.ts @@ -1,3 +1,4 @@ +import { createDurableCaptureSessionBinding } from './session-binding.ts'; import { AppError } from '@agent-device/kernel/errors'; import type { DurableCaptureSessionBinding, DurableCaptureSessionResource } from './definition.ts'; @@ -42,46 +43,18 @@ export function makeCaptureSessionBinding, ): 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({ + return createDurableCaptureSessionBinding({ address, sessionDir: store.resolveSessionDir(address), - read: () => { + initialSession: ref.session, + resolveCurrent: () => store.resolveCurrent(ref), + requireCurrent: () => { 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'; + if (session === undefined) throw new AppError('COMMAND_FAILED', 'Test session retired'); + return session; }, + assertAdmissionOpen: () => {}, + read: slot.read, + write: (resource) => store.update(ref, (current) => slot.replace(current, resource)), }); } diff --git a/packages/capture-kit/src/durable-capture/session-binding.test.ts b/packages/capture-kit/src/durable-capture/session-binding.test.ts new file mode 100644 index 0000000000..d6105c99c9 --- /dev/null +++ b/packages/capture-kit/src/durable-capture/session-binding.test.ts @@ -0,0 +1,37 @@ +import { expect, test, vi } from 'vitest'; +import { adoptStartedDurableCapture } from './adoption.ts'; +import { + makeDurableCaptureContext, + makeDurableCaptureStartResult, + testCaptureDefinition, +} from './durable-capture.fixtures.ts'; +import { createDurableCaptureSessionBinding } from './session-binding.ts'; + +test('closed admission refuses adoption before writing or retaining the resource', async () => { + const context = makeDurableCaptureContext(); + const start = makeDurableCaptureStartResult(context); + await adoptStartedDurableCapture( + testCaptureDefinition, + { ...context, ...start, throwIfCanceled: () => {} }, + context.resourcePath, + ); + const resource = context.binding.read()!; + const session = { capture: undefined }; + const write = vi.fn(); + const refusal = new Error('admission closed'); + const binding = createDurableCaptureSessionBinding({ + address: context.binding.address, + sessionDir: context.binding.sessionDir, + initialSession: session, + resolveCurrent: () => session, + requireCurrent: () => session, + assertAdmissionOpen: () => { + throw refusal; + }, + read: (current) => current.capture, + write, + }); + expect(() => binding.adopt(resource)).toThrow(refusal); + expect(write).not.toHaveBeenCalled(); + expect(binding.read()).toBeUndefined(); +}); diff --git a/packages/capture-kit/src/durable-capture/session-binding.ts b/packages/capture-kit/src/durable-capture/session-binding.ts new file mode 100644 index 0000000000..c3a338ba83 --- /dev/null +++ b/packages/capture-kit/src/durable-capture/session-binding.ts @@ -0,0 +1,59 @@ +import { AppError } from '@agent-device/kernel/errors'; +import type { DurableCaptureSessionBinding, DurableCaptureSessionResource } from './definition.ts'; + +export function createDurableCaptureSessionBinding( + port: Readonly<{ + address: string; + sessionDir: string; + initialSession: S; + resolveCurrent(): S | undefined; + requireCurrent(): S; + assertAdmissionOpen(): void; + read(session: S): DurableCaptureSessionResource | undefined; + write(resource: DurableCaptureSessionResource | undefined): void; + }>, +): DurableCaptureSessionBinding { + let retained = port.read(port.resolveCurrent() ?? port.initialSession); + const assertAdoptable = (): void => { + port.assertAdmissionOpen(); + if (port.read(port.requireCurrent())) { + throw new AppError('COMMAND_FAILED', 'Session capture resource has changed', { + reason: 'session_resource_changed', + session: port.address, + }); + } + }; + return Object.freeze({ + address: port.address, + sessionDir: port.sessionDir, + read: () => { + const current = port.resolveCurrent(); + if (current) retained = port.read(current); + return retained; + }, + assertAdoptable, + canPersist: () => { + const current = port.resolveCurrent(); + return current !== undefined && port.read(current) === undefined; + }, + adopt: (resource) => { + assertAdoptable(); + port.write(resource); + retained = resource; + }, + clear: (expected) => { + const current = port.resolveCurrent(); + if (!current) return 'retired'; + const active = port.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'; + port.write(undefined); + retained = undefined; + return 'cleared'; + }, + }); +} diff --git a/src/daemon/session-capture-binding.ts b/src/daemon/session-capture-binding.ts index bf509c92d4..f007cdb841 100644 --- a/src/daemon/session-capture-binding.ts +++ b/src/daemon/session-capture-binding.ts @@ -1,3 +1,4 @@ +import { createDurableCaptureSessionBinding } from '@agent-device/capture-kit/durable-capture'; import type { DurableCaptureSessionBinding, DurableCaptureSessionResource, @@ -14,48 +15,14 @@ export function bindSessionCapture( 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({ + return createDurableCaptureSessionBinding({ 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'; - }, + initialSession: ref.session, + resolveCurrent: () => sessionStore.resolveCurrent(ref), + requireCurrent: () => sessionStore.requireCurrent(ref), + assertAdmissionOpen: () => sessionStore.assertAdmissionOpen(ref.address), + ...slot, }); } From fdeb4e205d8c4d7609425fdf9f6893433b8c0adc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 4 Oct 2026 08:54:10 +0200 Subject: [PATCH 11/14] refactor: remove unused resource API until its consumer migration --- .../src/durable-capture/session-binding.ts | 4 ++-- src/daemon/app-log-session-resource.ts | 11 +---------- src/daemon/session-store.ts | 4 ---- 3 files changed, 3 insertions(+), 16 deletions(-) diff --git a/packages/capture-kit/src/durable-capture/session-binding.ts b/packages/capture-kit/src/durable-capture/session-binding.ts index c3a338ba83..0e73da17e7 100644 --- a/packages/capture-kit/src/durable-capture/session-binding.ts +++ b/packages/capture-kit/src/durable-capture/session-binding.ts @@ -28,7 +28,7 @@ export function createDurableCaptureSessionBinding { const current = port.resolveCurrent(); - if (current) retained = port.read(current); + if (current !== undefined) retained = port.read(current); return retained; }, assertAdoptable, @@ -43,7 +43,7 @@ export function createDurableCaptureSessionBinding { const current = port.resolveCurrent(); - if (!current) return 'retired'; + if (current === undefined) return 'retired'; const active = port.read(current); if ( active?.handle !== expected.handle || diff --git a/src/daemon/app-log-session-resource.ts b/src/daemon/app-log-session-resource.ts index 7645ea7128..d6ac0ea055 100644 --- a/src/daemon/app-log-session-resource.ts +++ b/src/daemon/app-log-session-resource.ts @@ -129,15 +129,6 @@ export function recordSessionAppLogFailure(params: { return normalized; } -export function clearSessionAppLogFailure(params: { - ref: SessionRef; - sessionStore: SessionStore; -}): void { - params.sessionStore.update(params.ref, { - appLogFailure: undefined, - }); -} - export function bindSessionAppLog(sessionStore: SessionStore, ref: SessionRef) { return bindSessionCapture(sessionStore, ref, { read: (session) => session.appLog, @@ -156,6 +147,6 @@ export function clearStoppedSessionAppLog(params: { ref: SessionRef; sessionStor ); } const cleared = clearAppLogFiles(params.sessionStore.resolveAppLogPath(params.ref.address)); - clearSessionAppLogFailure(params); + params.sessionStore.update(params.ref, { appLogFailure: undefined }); return cleared; } diff --git a/src/daemon/session-store.ts b/src/daemon/session-store.ts index 217b8b8dac..0dd68609aa 100644 --- a/src/daemon/session-store.ts +++ b/src/daemon/session-store.ts @@ -202,10 +202,6 @@ export class SessionStore { this.runtimeHints.set(name, hints); } - clearRuntimeHints(name: string): boolean { - return this.runtimeHints.delete(name); - } - recordAction(session: SessionState, entry: RecordActionEntry): void { const action = recordActionEntry(session, entry); if (action) { From b21f45b0291b99acb6c7d331aa50a98f126b370a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 4 Oct 2026 08:59:03 +0200 Subject: [PATCH 12/14] fix: retain runtime hint deletion required by current callers --- src/daemon/session-store.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/daemon/session-store.ts b/src/daemon/session-store.ts index 0dd68609aa..217b8b8dac 100644 --- a/src/daemon/session-store.ts +++ b/src/daemon/session-store.ts @@ -202,6 +202,10 @@ export class SessionStore { this.runtimeHints.set(name, hints); } + clearRuntimeHints(name: string): boolean { + return this.runtimeHints.delete(name); + } + recordAction(session: SessionState, entry: RecordActionEntry): void { const action = recordActionEntry(session, entry); if (action) { From 9644a9fecd5fc4c2548df42c647183bdc4b4f97e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 4 Oct 2026 09:25:42 +0200 Subject: [PATCH 13/14] fix: join daemon dispatches before retiring session admission --- .../durable-capture/session-binding.test.ts | 36 ++++ .../src/durable-capture/session-binding.ts | 1 + .../daemon-runtime-lifecycle-shutdown.test.ts | 171 +++++++++++++++--- src/daemon/server/daemon-runtime.ts | 23 ++- 4 files changed, 196 insertions(+), 35 deletions(-) diff --git a/packages/capture-kit/src/durable-capture/session-binding.test.ts b/packages/capture-kit/src/durable-capture/session-binding.test.ts index d6105c99c9..4a6ad12b7b 100644 --- a/packages/capture-kit/src/durable-capture/session-binding.test.ts +++ b/packages/capture-kit/src/durable-capture/session-binding.test.ts @@ -35,3 +35,39 @@ test('closed admission refuses adoption before writing or retaining the resource expect(write).not.toHaveBeenCalled(); expect(binding.read()).toBeUndefined(); }); + +test.each(['fence', 'handle', 'vacant'] as const)( + 'failed clear retains its latest observed %s slot after retirement', + async (change) => { + const context = makeDurableCaptureContext(); + const start = makeDurableCaptureStartResult(context); + await adoptStartedDurableCapture( + testCaptureDefinition, + { ...context, ...start, throwIfCanceled: () => {} }, + context.resourcePath, + ); + const original = context.binding.read()!; + const replacement = + change === 'vacant' + ? undefined + : change === 'handle' + ? { ...original, handle: makeDurableCaptureStartResult(context).handle } + : { + ...original, + envelope: { + ...original.envelope, + fence: { + ...original.envelope.fence, + generation: original.envelope.fence.generation + 1, + }, + }, + }; + context.sessions.set(context.sessionName, { + ...context.sessions.get(context.sessionName)!, + capture: replacement, + }); + expect(context.binding.clear(original)).toBe('resource-changed'); + context.sessions.retire(context.sessions.lookup(context.sessionName)); + expect(context.binding.read()).toBe(replacement); + }, +); diff --git a/packages/capture-kit/src/durable-capture/session-binding.ts b/packages/capture-kit/src/durable-capture/session-binding.ts index 0e73da17e7..dff5ccc4d3 100644 --- a/packages/capture-kit/src/durable-capture/session-binding.ts +++ b/packages/capture-kit/src/durable-capture/session-binding.ts @@ -45,6 +45,7 @@ export function createDurableCaptureSessionBinding ({ store: undefined as import('../session-store.ts').SessionStore | undefined, drain: undefined as (() => Promise) | undefined, finalize: vi.fn(async () => {}), + dispatch: undefined as import('../daemon-request.ts').DaemonInvokeFn | undefined, + closeTimeout: undefined as number | undefined, })); vi.mock('../session-store.ts', async (importOriginal) => { const actual = await importOriginal(); @@ -68,11 +70,22 @@ vi.mock('./server-shutdown.ts', async (importOriginal) => { return { ...actual, closeDaemonServers: async (...args: Parameters) => { - await actual.closeDaemonServers(...args); + await actual.closeDaemonServers(args[0], shutdownProbe.closeTimeout ?? args[1]); await shutdownProbe.drain?.(); }, }; }); +vi.mock('../request-router.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createRequestHandler: (...args: Parameters) => { + const dispatch = actual.createRequestHandler(...args); + return async (...request: Parameters) => + await (shutdownProbe.dispatch ?? dispatch)(...request); + }, + }; +}); vi.mock('../application-lifecycle-recovery.ts', async (importOriginal) => ({ ...(await importOriginal()), finalizeDaemonSessionApplicationLifecycle: shutdownProbe.finalize, @@ -84,11 +97,15 @@ import { isolatedDeviceClaimStores, retainOrphanedDeviceClaims, } from '../../__tests__/test-utils/device-claim-store.ts'; +import { sendRequest } from '../../daemon-client/daemon-client-transport.ts'; +import { resolveDaemonPaths } from '../../daemon-resolution.ts'; import { startDaemonRuntime } from './daemon-runtime.ts'; afterEach(() => { lifecycleEvents.length = 0; shutdownProbe.drain = undefined; + shutdownProbe.dispatch = undefined; + shutdownProbe.closeTimeout = undefined; shutdownProbe.finalize.mockReset(); }); @@ -165,40 +182,136 @@ test('shutdown includes drain publications, releases their claims and refuses po stderr: { write: () => {} }, stdout: { write: () => {} }, }); - expect(runtime).not.toBeNull(); - const store = shutdownProbe.store!; - const acquired = await acquireDeviceClaim({ - device: ANDROID_EMULATOR, - session: 'draining', - workspace: stateDir, - stateDir, - reconcileOrphanedDeviceClaim: retainOrphanedDeviceClaims, - }); - if (acquired.status !== 'acquired') throw new Error('Expected acquired claim'); - const session = { - name: 'draining', - device: ANDROID_EMULATOR, - createdAt: Date.now(), - actions: [], - deviceClaim: acquired.ownership, - }; - shutdownProbe.drain = async () => { - store.publish('draining', session); - }; - shutdownProbe.finalize.mockImplementationOnce(async () => { - expect(() => store.publish('late', { ...session, name: 'late' })).toThrowError( - expect.objectContaining({ - details: expect.objectContaining({ reason: 'daemon_shutting_down' }), - }), - ); - }); try { + expect(runtime).not.toBeNull(); + const store = shutdownProbe.store!; + const acquired = await acquireDeviceClaim({ + device: ANDROID_EMULATOR, + session: 'draining', + workspace: stateDir, + stateDir, + reconcileOrphanedDeviceClaim: retainOrphanedDeviceClaims, + }); + if (acquired.status !== 'acquired') throw new Error('Expected acquired claim'); + const session = { + name: 'draining', + device: ANDROID_EMULATOR, + createdAt: Date.now(), + actions: [], + deviceClaim: acquired.ownership, + }; + shutdownProbe.drain = async () => { + store.publish('draining', session); + }; + let refusal: unknown; + shutdownProbe.finalize.mockImplementationOnce(async () => { + try { + store.publish('late', { ...session, name: 'late' }); + } catch (error) { + refusal = error; + } + }); await runtime!.shutdown(); expect(shutdownProbe.finalize).toHaveBeenCalledOnce(); + expect(refusal).toMatchObject({ details: { reason: 'daemon_shutting_down' } }); expect(store.lookup('draining')).toBeUndefined(); expect(store.lookup('late')).toBeUndefined(); expect(fs.existsSync(resolveDeviceClaimPath(acquired.ownership.deviceKey))).toBe(false); } finally { - await runtime!.shutdown(); + await runtime?.shutdown(); + } +}); + +test('shutdown joins dispatch completion after force-closing the client before taking its snapshot', async () => { + const { stateDir } = claimStores(); + const runtime = await startDaemonRuntime({ + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: stateDir, + AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '0', + AGENT_DEVICE_DAEMON_SERVER_MODE: 'http', + }, + exit: () => {}, + registerProcessHandlers: false, + stderr: { write: () => {} }, + stdout: { write: () => {} }, + }); + let release!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + let entered!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + let closed!: () => void; + const serversClosed = new Promise((resolve) => { + closed = resolve; + }); + let request: Promise | undefined; + let shutdown: Promise | undefined; + try { + expect(runtime).not.toBeNull(); + const store = shutdownProbe.store!; + const acquired = await acquireDeviceClaim({ + device: ANDROID_EMULATOR, + session: 'dispatching', + workspace: stateDir, + stateDir, + reconcileOrphanedDeviceClaim: retainOrphanedDeviceClaims, + }); + if (acquired.status !== 'acquired') throw new Error('Expected acquired claim'); + let publicationError: unknown; + shutdownProbe.dispatch = async () => { + entered(); + await held; + try { + store.publish('dispatching', { + name: 'dispatching', + device: ANDROID_EMULATOR, + createdAt: Date.now(), + actions: [], + deviceClaim: acquired.ownership, + }); + } catch (error) { + publicationError = error; + } + return { ok: true }; + }; + request = sendRequest( + { httpPort: runtime!.httpPort, pid: process.pid, token: runtime!.token }, + { + token: runtime!.token, + session: 'dispatching', + command: 'open', + positionals: [], + flags: {}, + }, + 'http', + resolveDaemonPaths(stateDir), + 10_000, + ).catch((error: unknown) => error); + await started; + shutdownProbe.closeTimeout = 1; + shutdownProbe.drain = async () => { + closed(); + }; + shutdown = runtime!.shutdown(); + await serversClosed; + await new Promise((resolve) => setImmediate(resolve)); + expect(lifecycleEvents).not.toContain('detach'); + expect(shutdownProbe.finalize).not.toHaveBeenCalled(); + release(); + await shutdown; + await request; + expect(publicationError).toBeUndefined(); + expect(shutdownProbe.finalize).toHaveBeenCalledOnce(); + expect(store.lookup('dispatching')).toBeUndefined(); + expect(fs.existsSync(resolveDeviceClaimPath(acquired.ownership.deviceKey))).toBe(false); + } finally { + release(); + await request; + await shutdown; + await runtime?.shutdown(); } }); diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index e2bd61ea3e..6f19eb0cdb 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -578,10 +578,11 @@ export async function startDaemonRuntime( // in-flight requests, no active recording) past AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS. // `shutdown` is defined below but only invoked asynchronously by the timer, // well after this closure captures it. - let inFlightRequestCount = 0; + let shuttingDown = false; + const inFlightRequests = new Set>(); const idleReap = createDaemonIdleReap({ sessionStore, - getInFlightRequestCount: () => inFlightRequestCount, + getInFlightRequestCount: () => inFlightRequests.size, onIdleReap: () => { void shutdown(); }, @@ -589,12 +590,22 @@ export async function startDaemonRuntime( }); const handleRequest: DaemonInvokeFn = async (req) => { - inFlightRequestCount++; + if (shuttingDown) + return { + ok: false, + error: { + code: 'COMMAND_FAILED', + message: 'Daemon is shutting down.', + details: { reason: 'daemon_shutting_down' }, + }, + }; idleReap.cancel(); + const dispatch = dispatchRequest(req); + inFlightRequests.add(dispatch); try { - return await dispatchRequest(req); + return await dispatch; } finally { - inFlightRequestCount--; + inFlightRequests.delete(dispatch); idleReap.noteActivity(); // One hook covers every way a deadline changes: an `open` has added one, a `close` has removed // one, and any other command has just re-stamped the session it ran on. Reading the session set @@ -765,7 +776,6 @@ export async function startDaemonRuntime( // cannot start, PNG processing falls back to the in-process sync path. prewarmPngWorker(); - let shuttingDown = false; const shutdown = async (shutdownOptions: { exitCode?: number; cause?: unknown } = {}) => { idleReap.cancel(); sessionIdleExpiry.cancel(); @@ -776,6 +786,7 @@ export async function startDaemonRuntime( await emitFatalDiagnostic(shutdownOptions.cause); } await closeDaemonServers(servers); + await Promise.allSettled(inFlightRequests); // Hand healthy runners off before durable session teardown. The lifecycle gateway later // terminates only still-owned generations once all resources have finalized. // From 9252d8251fe1c2d95250f7b84e463db9a87e3e1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 4 Oct 2026 09:55:23 +0200 Subject: [PATCH 14/14] chore(gates): expose capture binding through a dedicated subpath --- packages/capture-kit/package.json | 4 ++++ packages/capture-kit/src/durable-capture/index.ts | 2 -- scripts/layering/package-boundaries.test.ts | 1 + src/daemon/session-capture-binding.ts | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/capture-kit/package.json b/packages/capture-kit/package.json index 556ef65fb3..7a8943f541 100644 --- a/packages/capture-kit/package.json +++ b/packages/capture-kit/package.json @@ -305,6 +305,10 @@ "./touch-reference-frame": { "types": "./src/snapshot/touch-reference-frame.ts", "default": "./src/snapshot/touch-reference-frame.ts" + }, + "./durable-capture/session-binding": { + "types": "./src/durable-capture/session-binding.ts", + "default": "./src/durable-capture/session-binding.ts" } }, "devDependencies": { diff --git a/packages/capture-kit/src/durable-capture/index.ts b/packages/capture-kit/src/durable-capture/index.ts index 58dbe4c62c..1d8f84b174 100644 --- a/packages/capture-kit/src/durable-capture/index.ts +++ b/packages/capture-kit/src/durable-capture/index.ts @@ -23,5 +23,3 @@ export type { } from './recovery.ts'; export type { DurableCaptureRecoveryControl } from './recovery-authority.ts'; export type { DurableCaptureResourceRecord, DurableCaptureResourceStore } from './store.ts'; - -export { createDurableCaptureSessionBinding } from './session-binding.ts'; diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index 3f41c6c817..cd18c8402d 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -401,6 +401,7 @@ test('the real tree parses, declares, and passes R11', () => { '@agent-device/capture-kit/durable-capture-admission-ledger', '@agent-device/capture-kit/durable-capture-resource', '@agent-device/capture-kit/durable-capture-runtime-recovery', + '@agent-device/capture-kit/durable-capture/session-binding', '@agent-device/capture-kit/durable-json', '@agent-device/capture-kit/ios-snapshot-acquisition', '@agent-device/capture-kit/ios-snapshot-engine', diff --git a/src/daemon/session-capture-binding.ts b/src/daemon/session-capture-binding.ts index f007cdb841..988a6a64ac 100644 --- a/src/daemon/session-capture-binding.ts +++ b/src/daemon/session-capture-binding.ts @@ -1,4 +1,4 @@ -import { createDurableCaptureSessionBinding } from '@agent-device/capture-kit/durable-capture'; +import { createDurableCaptureSessionBinding } from '@agent-device/capture-kit/durable-capture/session-binding'; import type { DurableCaptureSessionBinding, DurableCaptureSessionResource,