diff --git a/docs/adr/0030-process-lock-exclusion.md b/docs/adr/0030-process-lock-exclusion.md index 5aac07b2df..0e1458f936 100644 --- a/docs/adr/0030-process-lock-exclusion.md +++ b/docs/adr/0030-process-lock-exclusion.md @@ -40,6 +40,32 @@ guard cannot constrain legacy code after its final check. The support boundary t requires deployment control; host-kit does not claim to detect or evict every legacy user. Daemon registration has a separate cutover boundary: keep the same `daemon.lock` path and -refuse legacy files rather than automatically reclaiming them. Its startup tests must cover -an already-running older daemon and concurrent old/new startup. This does not expand the -host-kit mixed-protocol support contract. +refuse legacy files rather than automatically reclaiming them. A legacy daemon creates an +exclusive file; a hardened daemon creates a directory at that same path. The old acquisition +cannot unlink a directory, and the new acquisition retains an existing file. + +The [registration tests](../../src/__tests__/daemon-registration-owner.test.ts) exercise real +children using the c237027737 legacy acquisition and the current owner. They cover an older +daemon already running, a hardened owner waiting to publish metadata, and concurrent startup. +The client refuses an older registration before signaling or changing it. This proves the daemon +cutover; it does not expand the host-kit mixed-protocol support contract. Older clients still +require the deployment controls above. + +## Registration operations + +[Shared retirement](../../src/daemon-registration-owner.ts) owns verified termination, protected +metadata inspection and removal, and release. Takeover, failed startup, replay cleanup, timeout +reset and manual stop await its result. Abandoned recovery uses the same protected retirement +sequence without signaling a live process. Daemon publication and shutdown use functions bound +to their acquired claim. + +```mermaid +flowchart LR + C[Client lifecycle and timeout] --> R[Shared retirement] + M[Manual stop] --> R + P[Abandoned recovery and pruning] --> R + R --> L[Acquired registration claim] + D[Daemon startup and shutdown] --> O[Functions bound to own claim] + O --> L + L --> F[Protected metadata and reports] +``` diff --git a/packages/capture-kit/src/capture-admission/__tests__/audio-probe-session-resource.test.ts b/packages/capture-kit/src/capture-admission/__tests__/audio-probe-session-resource.test.ts index 073c33419b..c0e4d38565 100644 --- a/packages/capture-kit/src/capture-admission/__tests__/audio-probe-session-resource.test.ts +++ b/packages/capture-kit/src/capture-admission/__tests__/audio-probe-session-resource.test.ts @@ -1,3 +1,4 @@ +import { makeCaptureSessionBinding } from '../../durable-capture/session-binding.fixtures.ts'; import fs from 'node:fs/promises'; import path from 'node:path'; import { expect, test, vi } from 'vitest'; @@ -44,6 +45,10 @@ test('audio-probe disposes on a failed finish because terminating the helper is ); const session: DurableCaptureSessionState = {}; sessionStore.set(sessionName, session); + const binding = makeCaptureSessionBinding(sessionStore, sessionName, { + read: (session) => session.audioProbe, + replace: (session, audioProbe) => ({ ...session, audioProbe }), + }); const statusPath = path.join(sessionStore.resolveSessionDir(sessionName), 'audio-probe.json'); const terminate = vi.fn(async () => {}); let resolveExit!: (result: HostCommandResult) => void; @@ -85,9 +90,7 @@ test('audio-probe disposes on a failed finish because terminating the helper is }); await adoptStartedAudioProbe({ admissionLedger: createAudioProbeAdmissionLedger(), - session, - sessionName, - sessionStore, + binding, device, owner: localRuntimeOwner('apple'), fence, @@ -101,9 +104,7 @@ test('audio-probe disposes on a failed finish because terminating the helper is await expect( finishLiveAudioProbe({ intent: 'capture', - session: sessionStore.get(sessionName) ?? session, - sessionName, - sessionStore, + binding, }), ).rejects.toThrow('helper exited before completing the capture'); diff --git a/packages/capture-kit/src/capture-admission/__tests__/durable-capture-resource.fixtures.ts b/packages/capture-kit/src/capture-admission/__tests__/durable-capture-resource.fixtures.ts index 0cef989b60..291cd0d543 100644 --- a/packages/capture-kit/src/capture-admission/__tests__/durable-capture-resource.fixtures.ts +++ b/packages/capture-kit/src/capture-admission/__tests__/durable-capture-resource.fixtures.ts @@ -1,3 +1,4 @@ +import { makeCaptureSessionBinding } from '../../durable-capture/session-binding.fixtures.ts'; import { vi } from 'vitest'; import type { AppLogCompletion, AppLogLiveHandle } from '@agent-device/contracts/app-log-runtime'; import type { CleanupOutcome, FinishOutcome } from '@agent-device/contracts/durable-resource'; @@ -30,19 +31,10 @@ export const testCaptureStore = createDurableCaptureResourceStore({ export function createTestCaptureResource( store: DurableCaptureResourceStore<'app-log'> = testCaptureStore, ) { - return createDurableCaptureResource< - 'app-log', - AppLogLiveHandle, - AppLogCompletion, - TestCaptureSession - >({ + return createDurableCaptureResource<'app-log', AppLogLiveHandle, AppLogCompletion>({ resourceKind: 'app-log', displayName: 'test capture', store, - sessionSlot: { - read: (session) => session.appLog, - replace: (session, appLog) => ({ ...session, appLog, appLogFailure: undefined }), - }, completionMetadata: (completion) => ({ outputPath: completion.outputPath, completedAt: completion.completedAt, @@ -72,6 +64,10 @@ export function makeDurableCaptureContext( const session: TestCaptureSession = {}; sessionStore.set(sessionName, session); return { + binding: makeCaptureSessionBinding(sessionStore, sessionName, { + read: (session) => session.appLog, + replace: (session, appLog) => ({ ...session, appLog, appLogFailure: undefined }), + }), admissionLedger: createDurableCaptureAdmissionLedger({ displayName: 'test capture' }), session, sessionName, diff --git a/packages/capture-kit/src/capture-admission/__tests__/durable-capture-resource.test.ts b/packages/capture-kit/src/capture-admission/__tests__/durable-capture-resource.test.ts index a77610fac5..6a1e6e7a15 100644 --- a/packages/capture-kit/src/capture-admission/__tests__/durable-capture-resource.test.ts +++ b/packages/capture-kit/src/capture-admission/__tests__/durable-capture-resource.test.ts @@ -25,9 +25,7 @@ test('one coordinator exposes the typed manifest and all lifecycle entrypoints', await expect( testCaptureResource.finishLive({ intent: 'capture', - session: active, - sessionName: context.sessionName, - sessionStore: context.sessionStore, + binding: context.binding, }), ).resolves.toMatchObject({ outputPath: '/tmp/app.log', completedAt: 2 }); expect(context.sessionStore.get(context.sessionName)?.appLog).toBeUndefined(); diff --git a/packages/capture-kit/src/capture-admission/__tests__/screen-recording-boundary-faults.test.ts b/packages/capture-kit/src/capture-admission/__tests__/screen-recording-boundary-faults.test.ts index 775f5a3fae..e13d95baff 100644 --- a/packages/capture-kit/src/capture-admission/__tests__/screen-recording-boundary-faults.test.ts +++ b/packages/capture-kit/src/capture-admission/__tests__/screen-recording-boundary-faults.test.ts @@ -1,3 +1,4 @@ +import { makeCaptureSessionBinding } from '../../durable-capture/session-binding.fixtures.ts'; import path from 'node:path'; import { expect, test, vi } from 'vitest'; import { createDurableResourceEnvelope } from '../../durable-resource-envelope.ts'; @@ -187,16 +188,11 @@ function createScreenRecordingTestResource( return createDurableCaptureResource< 'screen-recording', ScreenRecordingLiveHandle, - ScreenRecordingCompletion, - DurableCaptureSessionState + ScreenRecordingCompletion >({ resourceKind: 'screen-recording', displayName: 'screen recording', store, - sessionSlot: { - read: (session) => session.screenRecording, - replace: (session, screenRecording) => ({ ...session, screenRecording }), - }, completionMetadata: (completion) => ({ backend: completion.backend, outputPath: completion.outPath, @@ -217,9 +213,14 @@ function makeContext(resource: ReturnType session.screenRecording, + replace: (session, screenRecording) => ({ ...session, screenRecording }), + }); return { admissionLedger: createDurableCaptureAdmissionLedger({ displayName: 'screen recording' }), session, + binding, sessionName, sessionStore, device, diff --git a/packages/capture-kit/src/capture-admission/__tests__/screen-recording-session-resource.test.ts b/packages/capture-kit/src/capture-admission/__tests__/screen-recording-session-resource.test.ts index b048db0b3c..99e3b36278 100644 --- a/packages/capture-kit/src/capture-admission/__tests__/screen-recording-session-resource.test.ts +++ b/packages/capture-kit/src/capture-admission/__tests__/screen-recording-session-resource.test.ts @@ -1,3 +1,4 @@ +import { makeCaptureSessionBinding } from '../../durable-capture/session-binding.fixtures.ts'; import { expect, test, vi } from 'vitest'; import { PendingTransferGuard } from '@agent-device/contracts/async-lifecycle'; import { localRuntimeOwner } from '@agent-device/contracts/platform-runtime'; @@ -35,6 +36,10 @@ test('screen recording persists durable truth before adopting only handle and en const sessionName = 'recording'; const session: TestRecordingSession = { name: sessionName, device }; sessionStore.set(sessionName, session); + const binding = makeCaptureSessionBinding(sessionStore, sessionName, { + read: (session) => session.screenRecording, + replace: (session, screenRecording) => ({ ...session, screenRecording }), + }); const owner = localRuntimeOwner('android'); const fence = { token: 'recording-fence', generation: 1 } as const; const finish = vi.fn(async () => ({ @@ -79,9 +84,7 @@ test('screen recording persists durable truth before adopting only handle and en await adoptStartedScreenRecording({ admissionLedger: createScreenRecordingAdmissionLedger(), - session, - sessionName, - sessionStore, + binding, device: session.device, owner, fence, @@ -101,9 +104,10 @@ test('screen recording persists durable truth before adopting only handle and en const active = sessionStore.get(sessionName); if (!active) throw new Error('Expected screen-recording session'); - await expect( - finishLiveScreenRecording({ intent: 'capture', session: active, sessionName, sessionStore }), - ).resolves.toMatchObject({ backend: 'android', outPath: '/tmp/recording.mp4' }); + await expect(finishLiveScreenRecording({ intent: 'capture', binding })).resolves.toMatchObject({ + backend: 'android', + outPath: '/tmp/recording.mp4', + }); expect(finish).toHaveBeenCalledOnce(); expect(sessionStore.get(sessionName)?.screenRecording).toBeUndefined(); }); @@ -115,6 +119,10 @@ test('a failed recording finish keeps the record open and never disposes the rec const sessionName = 'recording'; const session: TestRecordingSession = { name: sessionName, device }; sessionStore.set(sessionName, session); + const binding = makeCaptureSessionBinding(sessionStore, sessionName, { + read: (session) => session.screenRecording, + replace: (session, screenRecording) => ({ ...session, screenRecording }), + }); const owner = localRuntimeOwner('android'); const fence = { token: 'recording-fence', generation: 1 } as const; const finishError = new Error('failed to retrieve playable Android recording'); @@ -150,9 +158,7 @@ test('a failed recording finish keeps the record open and never disposes the rec }); await adoptStartedScreenRecording({ admissionLedger: createScreenRecordingAdmissionLedger(), - session, - sessionName, - sessionStore, + binding, device: session.device, owner, fence, @@ -163,9 +169,7 @@ test('a failed recording finish keeps the record open and never disposes the rec const active = sessionStore.get(sessionName); if (!active) throw new Error('Expected screen-recording session'); - await expect( - finishLiveScreenRecording({ intent: 'capture', session: active, sessionName, sessionStore }), - ).rejects.toBe(finishError); + await expect(finishLiveScreenRecording({ intent: 'capture', binding })).rejects.toBe(finishError); expect(forceCleanup).not.toHaveBeenCalled(); expect(sessionStore.get(sessionName)?.screenRecording?.handle).toBe(handle); @@ -182,6 +186,10 @@ test('a record stop that fails after collecting resumes through the fence withou const sessionName = 'recording'; const session: TestRecordingSession = { name: sessionName, device }; sessionStore.set(sessionName, session); + const binding = makeCaptureSessionBinding(sessionStore, sessionName, { + read: (session) => session.screenRecording, + replace: (session, screenRecording) => ({ ...session, screenRecording }), + }); const owner = localRuntimeOwner('android'); const fence = { token: 'recording-fence', generation: 1 } as const; const signals = vi.fn(async () => ({ observation: { recorder: 'confirmed' as const } })); @@ -216,9 +224,7 @@ test('a record stop that fails after collecting resumes through the fence withou ); await adoptStartedScreenRecording({ admissionLedger: createScreenRecordingAdmissionLedger(), - session, - sessionName, - sessionStore, + binding, device: session.device, owner, fence, @@ -239,9 +245,7 @@ test('a record stop that fails after collecting resumes through the fence withou if (!active) throw new Error('Expected screen-recording session'); return finishLiveScreenRecording({ intent: 'capture', - session: active, - sessionName, - sessionStore, + binding, }); }; diff --git a/packages/capture-kit/src/capture-admission/__tests__/session-store.fixtures.ts b/packages/capture-kit/src/capture-admission/__tests__/session-store.fixtures.ts index dee8763861..02cc494180 100644 --- a/packages/capture-kit/src/capture-admission/__tests__/session-store.fixtures.ts +++ b/packages/capture-kit/src/capture-admission/__tests__/session-store.fixtures.ts @@ -1,28 +1,16 @@ import path from 'node:path'; import { safeSessionName } from '@agent-device/host-kit/session-paths'; -import type { DurableCaptureSessionStore } from '../../durable-capture/index.ts'; import { mkdtempForTestSync } from '../../tmp-dir.fixtures.ts'; +import { makeCaptureFixtureStore } from '../../durable-capture/session-binding.fixtures.ts'; -export type CaptureAdmissionSessionStore = DurableCaptureSessionStore & - Readonly<{ - get(name: string): S | undefined; - sessionsDir: string; - }>; +export type CaptureAdmissionSessionStore = ReturnType< + typeof makeCaptureAdmissionSessionStore +>; -/** - * The whole of the daemon `SessionStore` these admission modules ever address — `set`, - * `resolveSessionDir`, and the read-back a test asserts on — over a fresh temp directory, so a - * test of this family needs no session record, store class, or daemon import. - */ -export function makeCaptureAdmissionSessionStore( - prefix: string, -): CaptureAdmissionSessionStore { +export function makeCaptureAdmissionSessionStore(prefix: string) { const sessionsDir = mkdtempForTestSync(prefix); - const sessions = new Map(); - return { - set: (name, session) => void sessions.set(name, session), - get: (name) => sessions.get(name), - resolveSessionDir: (name) => path.join(sessionsDir, safeSessionName(name)), + return Object.freeze({ + ...makeCaptureFixtureStore((name) => path.join(sessionsDir, safeSessionName(name))), sessionsDir, - }; + }); } diff --git a/packages/capture-kit/src/capture-admission/audio-probe-session-resource.ts b/packages/capture-kit/src/capture-admission/audio-probe-session-resource.ts index 83863d3336..21f2b91f1a 100644 --- a/packages/capture-kit/src/capture-admission/audio-probe-session-resource.ts +++ b/packages/capture-kit/src/capture-admission/audio-probe-session-resource.ts @@ -9,26 +9,20 @@ import type { RuntimeOwnerRef, } from '@agent-device/contracts/platform-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import type { DurableCaptureSessionStore } from '../durable-capture/index.ts'; +import type { DurableCaptureSessionBinding } from '../durable-capture/index.ts'; import { createDurableCaptureResource } from './durable-capture-resource.ts'; import type { DurableCaptureFinishIntent } from './durable-capture-resource.ts'; import type { AudioProbeAdmissionLedger } from './audio-probe-admission-ledger.ts'; import { audioProbeResourceStore } from './audio-probe-resource-store.ts'; -import type { DurableCaptureSessionState } from './session-state-slice.ts'; export const audioProbeDurableResource = createDurableCaptureResource< 'audio-probe', AudioProbeLiveHandle, - AudioProbeCompletion, - DurableCaptureSessionState + AudioProbeCompletion >({ resourceKind: 'audio-probe', displayName: 'audio probe', store: audioProbeResourceStore, - sessionSlot: { - read: (session) => session.audioProbe, - replace: (session, audioProbe) => ({ ...session, audioProbe }), - }, completionMetadata: (completion) => ({ backend: completion.backend ?? 'unknown', source: completion.source, @@ -48,9 +42,7 @@ export const audioProbeDurableResource = createDurableCaptureResource< export function adoptStartedAudioProbe(params: { admissionLedger: AudioProbeAdmissionLedger; - session: DurableCaptureSessionState; - sessionName: string; - sessionStore: DurableCaptureSessionStore; + binding: DurableCaptureSessionBinding<'audio-probe', AudioProbeLiveHandle>; device: DeviceInfo; owner: RuntimeOwnerRef; fence: ResourceOwnershipFence; @@ -62,9 +54,7 @@ export function adoptStartedAudioProbe(params: { } export function finishLiveAudioProbe(params: { - session: DurableCaptureSessionState; - sessionName: string; - sessionStore: DurableCaptureSessionStore; + binding: DurableCaptureSessionBinding<'audio-probe', AudioProbeLiveHandle>; intent: DurableCaptureFinishIntent; }): Promise { return audioProbeDurableResource.finishLive(params); diff --git a/packages/capture-kit/src/capture-admission/durable-capture-resource.ts b/packages/capture-kit/src/capture-admission/durable-capture-resource.ts index a03751be14..8a9800e69c 100644 --- a/packages/capture-kit/src/capture-admission/durable-capture-resource.ts +++ b/packages/capture-kit/src/capture-admission/durable-capture-resource.ts @@ -9,8 +9,8 @@ import { type AdoptStartedDurableCaptureParams, type DurableCaptureFinishIntent, type DurableCaptureRecoveryParams, - type DurableCaptureResourceDefinition, - type DurableCaptureSessionStore, + type DurableCaptureRecordDefinition, + type DurableCaptureSessionBinding, type FinishRecoveredDurableCaptureParams, } from '../durable-capture/index.ts'; import type { LiveResourceHandle } from '@agent-device/contracts/durable-resource'; @@ -23,8 +23,8 @@ import type { DurableSessionResourceKind } from './durable-session-resource-kind export type { DurableCaptureFinishIntent, DurableSessionResourceKind }; -type AdoptStartedSessionCaptureParams = Omit< - AdoptStartedDurableCaptureParams, +type AdoptStartedSessionCaptureParams = Omit< + AdoptStartedDurableCaptureParams, 'reportUndurableCleanup' > & Readonly<{ admissionLedger: DurableCaptureAdmissionLedger }>; @@ -34,21 +34,14 @@ type SessionCaptureRecoveryParams; -/** - * Where the shared durable-capture mechanics meet the two authorities that stay with the session - * owner: the admission ledger, which decides whether a failed adoption blocks a replacement start, - * and the session store, whose naming rule turns a session id into the one directory its records - * may occupy. The session record itself stays opaque behind `S`; only the definition's own - * `sessionSlot` looks inside it. - */ +/** The session binding owns its slot and path; the ledger owns failed-adoption admission. */ export function createDurableCaptureResource< K extends DurableSessionResourceKind, H extends LiveResourceHandle, C, - S, ->(definition: DurableCaptureResourceDefinition) { +>(definition: DurableCaptureRecordDefinition) { const sessionResourcePath = ( - sessionStore: DurableCaptureSessionStore, + sessionStore: Readonly<{ resolveSessionDir(name: string): string }>, sessionName: string, ): string => definition.store.resolvePath(sessionStore.resolveSessionDir(sessionName)); const recoveryParams = ( @@ -70,7 +63,7 @@ export function createDurableCaptureResource< }): ResourceOwnershipFence { return createNextDurableCaptureFence(definition, params); }, - adoptStarted(params: AdoptStartedSessionCaptureParams): Promise { + adoptStarted(params: AdoptStartedSessionCaptureParams): Promise { return adoptStartedDurableCapture( definition, { @@ -80,31 +73,27 @@ export function createDurableCaptureResource< else params.admissionLedger.blockUndurableCleanup(device, outcome.reason); }, }, - sessionResourcePath(params.sessionStore, params.sessionName), + definition.store.resolvePath(params.binding.sessionDir), ); }, finishLive(params: { - session: S; - sessionName: string; - sessionStore: DurableCaptureSessionStore; + binding: DurableCaptureSessionBinding; intent: DurableCaptureFinishIntent; }): Promise { return finishLiveDurableCapture( definition, params, - sessionResourcePath(params.sessionStore, params.sessionName), + definition.store.resolvePath(params.binding.sessionDir), ); }, finishRecovered(params: FinishRecoveredDurableCaptureParams): Promise { return finishRecoveredDurableCapture(definition, params); }, - forceCleanupLive(params: { - session: S; - sessionName?: string; - sessionStore?: DurableCaptureSessionStore; - resourcePath: string; - }): Promise { - return forceCleanupLiveDurableCapture(definition, params); + forceCleanupLive(params: { binding: DurableCaptureSessionBinding }): Promise { + return forceCleanupLiveDurableCapture(definition, { + ...params, + resourcePath: definition.store.resolvePath(params.binding.sessionDir), + }); }, recoverAll(params: SessionCaptureRecoveryParams) { return recoverDurableCaptureResourcesAfterDaemonLock(recoveryParams(params)); diff --git a/packages/capture-kit/src/capture-admission/perf-capture-session-resource.ts b/packages/capture-kit/src/capture-admission/perf-capture-session-resource.ts index 6e6e14104b..c6dbee2515 100644 --- a/packages/capture-kit/src/capture-admission/perf-capture-session-resource.ts +++ b/packages/capture-kit/src/capture-admission/perf-capture-session-resource.ts @@ -9,26 +9,20 @@ import type { RuntimeOwnerRef, } from '@agent-device/contracts/platform-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import type { DurableCaptureSessionStore } from '../durable-capture/index.ts'; +import type { DurableCaptureSessionBinding } from '../durable-capture/index.ts'; import { createDurableCaptureResource } from './durable-capture-resource.ts'; import type { DurableCaptureFinishIntent } from './durable-capture-resource.ts'; import type { PerfCaptureAdmissionLedger } from './perf-capture-admission-ledger.ts'; import { perfCaptureResourceStore } from './perf-capture-resource-store.ts'; -import type { DurableCaptureSessionState } from './session-state-slice.ts'; export const perfCaptureDurableResource = createDurableCaptureResource< 'perf-capture', PerfNativeCaptureLiveHandle, - PerfNativeCaptureCompletion, - DurableCaptureSessionState + PerfNativeCaptureCompletion >({ resourceKind: 'perf-capture', displayName: 'perf capture', store: perfCaptureResourceStore, - sessionSlot: { - read: (session) => session.perfCapture, - replace: (session, perfCapture) => ({ ...session, perfCapture }), - }, completionMetadata: (completion) => ({ kind: typeof completion.kind === 'string' ? completion.kind : 'unknown', mode: typeof completion.mode === 'string' ? completion.mode : 'unknown', @@ -46,9 +40,7 @@ export const perfCaptureDurableResource = createDurableCaptureResource< export function adoptStartedPerfCapture(params: { admissionLedger: PerfCaptureAdmissionLedger; - session: DurableCaptureSessionState; - sessionName: string; - sessionStore: DurableCaptureSessionStore; + binding: DurableCaptureSessionBinding<'perf-capture', PerfNativeCaptureLiveHandle>; device: DeviceInfo; owner: RuntimeOwnerRef; fence: ResourceOwnershipFence; @@ -60,9 +52,7 @@ export function adoptStartedPerfCapture(params: { } export function finishLivePerfCapture(params: { - session: DurableCaptureSessionState; - sessionName: string; - sessionStore: DurableCaptureSessionStore; + binding: DurableCaptureSessionBinding<'perf-capture', PerfNativeCaptureLiveHandle>; intent: DurableCaptureFinishIntent; }): Promise { return perfCaptureDurableResource.finishLive(params); diff --git a/packages/capture-kit/src/capture-admission/screen-recording-session-resource.ts b/packages/capture-kit/src/capture-admission/screen-recording-session-resource.ts index 66b54f4bcc..9a477dfbaf 100644 --- a/packages/capture-kit/src/capture-admission/screen-recording-session-resource.ts +++ b/packages/capture-kit/src/capture-admission/screen-recording-session-resource.ts @@ -16,27 +16,21 @@ import type { StopObservation } from '@agent-device/contracts/recording-stop-obs import type { DeviceInfo } from '@agent-device/kernel/device'; import type { DurableCaptureRecoveryControl, - DurableCaptureSessionStore, + DurableCaptureSessionBinding, } from '../durable-capture/index.ts'; import { createDurableCaptureResource } from './durable-capture-resource.ts'; import type { DurableCaptureFinishIntent } from './durable-capture-resource.ts'; import type { ScreenRecordingAdmissionLedger } from './screen-recording-admission-ledger.ts'; import { screenRecordingResourceStore } from './screen-recording-resource-store.ts'; -import type { DurableCaptureSessionState } from './session-state-slice.ts'; export const screenRecordingDurableResource = createDurableCaptureResource< 'screen-recording', ScreenRecordingLiveHandle, - ScreenRecordingCompletion, - DurableCaptureSessionState + ScreenRecordingCompletion >({ resourceKind: 'screen-recording', displayName: 'screen recording', store: screenRecordingResourceStore, - sessionSlot: { - read: (session) => session.screenRecording, - replace: (session, screenRecording) => ({ ...session, screenRecording }), - }, completionMetadata: encodeScreenRecordingCompletionMetadata, // ADR 0024 rule 6: the next stop re-collects the native artifact a failed export left behind, and // forced cleanup would delete exactly that. Disposal belongs to teardown and start rollback. @@ -50,9 +44,7 @@ export const screenRecordingDurableResource = createDurableCaptureResource< export function adoptStartedScreenRecording(params: { admissionLedger: ScreenRecordingAdmissionLedger; - session: DurableCaptureSessionState; - sessionName: string; - sessionStore: DurableCaptureSessionStore; + binding: DurableCaptureSessionBinding<'screen-recording', ScreenRecordingLiveHandle>; device: DeviceInfo; owner: RuntimeOwnerRef; fence: ResourceOwnershipFence; @@ -64,9 +56,7 @@ export function adoptStartedScreenRecording(params: { } export function finishLiveScreenRecording(params: { - session: DurableCaptureSessionState; - sessionName: string; - sessionStore: DurableCaptureSessionStore; + binding: DurableCaptureSessionBinding<'screen-recording', ScreenRecordingLiveHandle>; intent: DurableCaptureFinishIntent; }): Promise { return screenRecordingDurableResource.finishLive(params); diff --git a/packages/capture-kit/src/capture-admission/screen-recording-stop-recovery.ts b/packages/capture-kit/src/capture-admission/screen-recording-stop-recovery.ts index b59ee12d04..585a292243 100644 --- a/packages/capture-kit/src/capture-admission/screen-recording-stop-recovery.ts +++ b/packages/capture-kit/src/capture-admission/screen-recording-stop-recovery.ts @@ -12,15 +12,11 @@ import { deviceIdentity, sameDeviceIdentity, type DeviceInfo } from '@agent-devi import { AppError } from '@agent-device/kernel/errors'; import { isRecord } from '@agent-device/kernel/record'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; -import type { - DurableCaptureResourceRecord, - DurableCaptureSessionStore, -} from '../durable-capture/index.ts'; +import type { DurableCaptureResourceRecord } from '../durable-capture/index.ts'; import { SCREEN_RECORDING_COMPLETION_METADATA_KEY, screenRecordingDurableResource, } from './screen-recording-session-resource.ts'; -import type { DurableCaptureSessionState } from './session-state-slice.ts'; /** * What a `record stop` owes a session, decided from the durable recording manifest alone. @@ -45,7 +41,7 @@ const OPTIONAL_RESPONSE_FIELDS = [ type ScreenRecordingManifestParams = Readonly<{ sessionName: string; - sessionStore: DurableCaptureSessionStore; + sessionStore: Readonly<{ resolveSessionDir(name: string): string }>; }>; export function resolveScreenRecordingStopRecovery( diff --git a/packages/capture-kit/src/durable-capture/adoption.test.ts b/packages/capture-kit/src/durable-capture/adoption.test.ts index ac2cbff303..f3fb4a521d 100644 --- a/packages/capture-kit/src/durable-capture/adoption.test.ts +++ b/packages/capture-kit/src/durable-capture/adoption.test.ts @@ -68,3 +68,25 @@ test('a failed terminal transition preserves the primary error and reports it un reason: expect.stringMatching(/./), }); }); + +test('adoption refuses a retired lifetime even when its address has a vacant successor', async () => { + const context = makeDurableCaptureContext(); + const start = makeDurableCaptureStartResult(context); + context.sessionStore.retire(context.sessionStore.lookup(context.sessionName)); + const successor = { name: 'successor' }; + context.sessionStore.set(context.sessionName, successor); + await expect( + adoptStartedDurableCapture( + testCaptureDefinition, + { + ...context, + ...start, + throwIfCanceled: () => {}, + }, + context.resourcePath, + ), + ).rejects.toMatchObject({ code: 'COMMAND_FAILED' }); + expect(start.forceCleanup).toHaveBeenCalledOnce(); + expect(context.sessionStore.get(context.sessionName)).toBe(successor); + expect(testCaptureStore.read(context.resourcePath).status).toBe('missing'); +}); diff --git a/packages/capture-kit/src/durable-capture/adoption.ts b/packages/capture-kit/src/durable-capture/adoption.ts index 2390539458..fef4f1661a 100644 --- a/packages/capture-kit/src/durable-capture/adoption.ts +++ b/packages/capture-kit/src/durable-capture/adoption.ts @@ -15,7 +15,6 @@ import { import type { AdoptStartedDurableCaptureParams, DurableCaptureRecordDefinition, - DurableCaptureResourceDefinition, } from './definition.ts'; import { capitalizeDurableCaptureLabel, durableCaptureDiagnosticPrefix } from './labels.ts'; @@ -28,46 +27,43 @@ export async function adoptStartedDurableCapture< K extends string, H extends LiveResourceHandle, C, - S, >( - definition: DurableCaptureResourceDefinition, - params: AdoptStartedDurableCaptureParams, + definition: DurableCaptureRecordDefinition, + params: AdoptStartedDurableCaptureParams, resourcePath: string, ): Promise { let state: AdoptionState = { kind: 'pending' }; try { + params.binding.assertAdoptable(); const envelope = withPhase(validateStartedEnvelope(definition, params), 'active'); definition.store.write(resourcePath, envelope); state = { kind: 'persisted' }; params.throwIfCanceled(); const handle = params.pendingHandle.transfer(); state = { kind: 'transferred', handle }; - params.sessionStore.set( - params.sessionName, - definition.sessionSlot.replace(params.session, { handle, envelope }), - ); + params.binding.adopt({ handle, envelope }); } catch (error) { await recoverFailedAdoption(definition, params, resourcePath, state, error); throw error; } } -async function recoverFailedAdoption, C, S>( - definition: DurableCaptureResourceDefinition, - params: AdoptStartedDurableCaptureParams, +async function recoverFailedAdoption, C>( + definition: DurableCaptureRecordDefinition, + params: AdoptStartedDurableCaptureParams, resourcePath: string, state: AdoptionState, primaryError: unknown, ): Promise { + const mayPersist = params.binding.canPersist(); const persisted = - state.kind === 'pending' ? persistRecoveryTombstone(definition, params, resourcePath) : true; + state.kind === 'pending' + ? mayPersist && persistRecoveryTombstone(definition, params, resourcePath) + : true; const initialCleanupError = await disposeFailedAdoption(params, state); - const transition = confirmFailedAdoptionTransition( - definition, - params, - resourcePath, - initialCleanupError, - ); + const transition = !params.binding.canPersist() + ? { confirmed: false, cleanupError: initialCleanupError } + : confirmFailedAdoptionTransition(definition, params, resourcePath, initialCleanupError); params.reportUndurableCleanup( params.device, (!persisted && transition.cleanupError === undefined) || transition.confirmed @@ -84,9 +80,9 @@ async function recoverFailedAdoption, C, S>( +function confirmFailedAdoptionTransition, C>( definition: DurableCaptureRecordDefinition, - params: Pick, 'sessionName' | 'fence'>, + params: Pick, 'binding' | 'fence'>, resourcePath: string, cleanupError: unknown | undefined, ): { confirmed: boolean; cleanupError: unknown | undefined } { @@ -100,7 +96,7 @@ function confirmFailedAdoptionTransition( - params: AdoptStartedDurableCaptureParams, +async function disposeFailedAdoption( + params: AdoptStartedDurableCaptureParams, state: AdoptionState, ): Promise { try { @@ -122,16 +118,13 @@ async function disposeFailedAdoption, C, S>( +function persistRecoveryTombstone, C>( definition: DurableCaptureRecordDefinition, - params: AdoptStartedDurableCaptureParams, + params: AdoptStartedDurableCaptureParams, resourcePath: string, ): boolean { try { - definition.store.write( - resourcePath, - createExpectedEnvelope(definition, params, params.envelope.descriptor), - ); + definition.store.write(resourcePath, createRecoveryEnvelope(definition, params)); return true; } catch (descriptorError) { try { @@ -148,7 +141,7 @@ function persistRecoveryTombstone, C, S>( +function createRecoveryEnvelope, C>( definition: DurableCaptureRecordDefinition, - params: Pick< - AdoptStartedDurableCaptureParams, - 'sessionName' | 'device' | 'owner' | 'fence' - >, + params: AdoptStartedDurableCaptureParams, +): DurableResourceEnvelope { + try { + return withPhase(validateStartedEnvelope(definition, params), 'active'); + } catch { + return createExpectedEnvelope(definition, params, params.envelope.descriptor); + } +} + +function createExpectedEnvelope, C>( + definition: DurableCaptureRecordDefinition, + params: Pick, 'binding' | 'device' | 'owner' | 'fence'>, descriptor: DurableResourceEnvelope['descriptor'], ): DurableResourceEnvelope { return createDurableResourceEnvelope({ resourceKind: definition.resourceKind, - sessionId: params.sessionName, + sessionId: params.binding.address, device: deviceIdentity(params.device), owner: params.owner, fence: params.fence, @@ -180,11 +181,11 @@ function createExpectedEnvelope, C, S>( +function validateStartedEnvelope, C>( definition: DurableCaptureRecordDefinition, params: Pick< - AdoptStartedDurableCaptureParams, - 'sessionName' | 'device' | 'owner' | 'fence' | 'envelope' + AdoptStartedDurableCaptureParams, + 'binding' | 'device' | 'owner' | 'fence' | 'envelope' >, ): DurableResourceEnvelope { const decoded = decodeDurableResourceEnvelope(params.envelope); @@ -207,7 +208,7 @@ function validateStartedEnvelope( return true; } -function emitCleanupDiagnostic( +function emitCleanupDiagnostic( definition: DurableCaptureRecordDefinition, - params: Pick, 'sessionName'>, + params: Pick, 'binding'>, primaryError: unknown, cleanupError: unknown, ): void { @@ -260,7 +261,7 @@ function emitCleanupDiagnostic = Readonly<{ - set(name: string, session: S): void; - resolveSessionDir(name: string): string; -}>; - export type DurableCaptureSessionResource = Readonly<{ handle: H; envelope: DurableResourceEnvelope; }>; -export type DurableCaptureSessionSlot = Readonly<{ - read(session: S): DurableCaptureSessionResource | undefined; - replace(session: S, resource: DurableCaptureSessionResource | undefined): S; +export type DurableCaptureSessionBinding = Readonly<{ + address: string; + sessionDir: string; + read(): DurableCaptureSessionResource | undefined; + assertAdoptable(): void; + canPersist(): boolean; + adopt(resource: DurableCaptureSessionResource): void; + clear(expected: DurableCaptureSessionResource): 'cleared' | 'retired' | 'resource-changed'; }>; /** @@ -70,14 +64,6 @@ export type DurableCaptureRecordDefinition = Readonly<{ }>; }>; -export type DurableCaptureResourceDefinition< - K extends string, - H extends LiveResourceHandle, - C, - S, -> = DurableCaptureRecordDefinition & - Readonly<{ sessionSlot: DurableCaptureSessionSlot }>; - /** * What the mechanics observed about a failed adoption's cleanup. Reporting it keeps the * admission decision — block a replacement start, or clear an earlier block — with the caller. @@ -86,11 +72,9 @@ export type DurableCaptureCleanupOutcome = | { confirmed: true } | { confirmed: false; reason: string }; -export type AdoptStartedDurableCaptureParams = { +export type AdoptStartedDurableCaptureParams = { reportUndurableCleanup(device: DeviceInfo, outcome: DurableCaptureCleanupOutcome): void; - session: S; - sessionName: string; - sessionStore: DurableCaptureSessionStore; + binding: DurableCaptureSessionBinding; device: DeviceInfo; owner: RuntimeOwnerRef; fence: ResourceOwnershipFence; diff --git a/packages/capture-kit/src/durable-capture/durable-capture.fixtures.ts b/packages/capture-kit/src/durable-capture/durable-capture.fixtures.ts index 61bdf5d280..9496e6845f 100644 --- a/packages/capture-kit/src/durable-capture/durable-capture.fixtures.ts +++ b/packages/capture-kit/src/durable-capture/durable-capture.fixtures.ts @@ -1,3 +1,4 @@ +import { makeCaptureSessionBinding, makeCaptureFixtureStore } from './session-binding.fixtures.ts'; import path from 'node:path'; import { vi, type Mock } from 'vitest'; import { @@ -13,9 +14,8 @@ import { mkdtempForTestSync } from '../tmp-dir.fixtures.ts'; import type { DurableCaptureCleanupOutcome, DurableCaptureFailedFinishPolicy, - DurableCaptureResourceDefinition, + DurableCaptureRecordDefinition, DurableCaptureSessionResource, - DurableCaptureSessionStore, } from './definition.ts'; import { createDurableCaptureResourceStore, type DurableCaptureResourceStore } from './store.ts'; @@ -49,21 +49,12 @@ export const testCaptureStore = createDurableCaptureResourceStore({ export function createTestCaptureDefinition( store: DurableCaptureResourceStore = testCaptureStore, failedFinishPolicy: DurableCaptureFailedFinishPolicy = 'dispose-on-failed-finish', -): DurableCaptureResourceDefinition< - typeof TEST_CAPTURE_KIND, - TestCaptureHandle, - TestCaptureCompletion, - TestCaptureSession -> { +): DurableCaptureRecordDefinition { return { resourceKind: TEST_CAPTURE_KIND, displayName: 'test capture', store, failedFinishPolicy, - sessionSlot: { - read: (session) => session.capture, - replace: (session, capture) => ({ ...session, capture }), - }, completionMetadata: (completion) => ({ outputPath: completion.outputPath, completedAt: completion.completedAt, @@ -87,20 +78,20 @@ export function makeDurableCaptureContext( ) { const sessionsDir = mkdtempForTestSync('durable-capture-resource-'); const sessionName = 'session'; - const sessions = new Map(); const session: TestCaptureSession = { name: sessionName }; - sessions.set(sessionName, session); const resolveSessionDir = (name: string): string => path.join(sessionsDir, name); - const sessionStore: DurableCaptureSessionStore = { - set: (name, next) => void sessions.set(name, next), - resolveSessionDir, - }; + const sessionStore = makeCaptureFixtureStore(resolveSessionDir); + sessionStore.set(sessionName, session); const reportUndurableCleanup: Mock< (device: DeviceInfo, outcome: DurableCaptureCleanupOutcome) => void > = vi.fn(); return { reportUndurableCleanup, - sessions, + binding: makeCaptureSessionBinding(sessionStore, sessionName, { + read: (session) => session.capture, + replace: (session, capture) => ({ ...session, capture }), + }), + sessions: sessionStore, sessionsDir, resolveSessionDir, session, diff --git a/packages/capture-kit/src/durable-capture/index.ts b/packages/capture-kit/src/durable-capture/index.ts index 09013e180a..1d8f84b174 100644 --- a/packages/capture-kit/src/durable-capture/index.ts +++ b/packages/capture-kit/src/durable-capture/index.ts @@ -12,9 +12,8 @@ export type { AdoptStartedDurableCaptureParams, DurableCaptureFinishIntent, DurableCaptureRecordDefinition, - DurableCaptureResourceDefinition, DurableCaptureSessionResource, - DurableCaptureSessionStore, + DurableCaptureSessionBinding, } from './definition.ts'; export type { FinishRecoveredDurableCaptureParams } from './finish-recovered.ts'; export type { diff --git a/packages/capture-kit/src/durable-capture/session-binding.fixtures.ts b/packages/capture-kit/src/durable-capture/session-binding.fixtures.ts new file mode 100644 index 0000000000..e84740ee55 --- /dev/null +++ b/packages/capture-kit/src/durable-capture/session-binding.fixtures.ts @@ -0,0 +1,87 @@ +import { AppError } from '@agent-device/kernel/errors'; +import type { DurableCaptureSessionBinding, DurableCaptureSessionResource } from './definition.ts'; + +type FixtureSessionRef = Readonly<{ address: string; session: S; lifetime: object }>; + +export function makeCaptureFixtureStore(resolveSessionDir: (address: string) => string) { + const entries = new Map(); + const resolveCurrent = (ref: FixtureSessionRef): S | undefined => { + const entry = entries.get(ref.address); + return entry === ref.lifetime ? entry.current : undefined; + }; + return Object.freeze({ + resolveSessionDir, + get: (address: string): S | undefined => entries.get(address)?.current, + set: (address: string, session: S): void => { + const entry = entries.get(address); + if (entry) entry.current = session; + else entries.set(address, { current: session }); + }, + lookup: (address: string): FixtureSessionRef => { + const entry = entries.get(address); + if (!entry) throw new AppError('COMMAND_FAILED', 'Test session retired'); + return Object.freeze({ address, session: entry.current, lifetime: entry }); + }, + resolveCurrent, + update: (ref: FixtureSessionRef, rebuild: (current: S) => S): void => { + const current = resolveCurrent(ref); + if (current === undefined) throw new AppError('COMMAND_FAILED', 'Test session retired'); + entries.get(ref.address)!.current = rebuild(current); + }, + retire: (ref: FixtureSessionRef): boolean => + resolveCurrent(ref) !== undefined && entries.delete(ref.address), + }); +} + +export function makeCaptureSessionBinding( + store: ReturnType>, + address: string, + slot: Readonly<{ + read(session: S): DurableCaptureSessionResource | undefined; + replace(session: S, resource: DurableCaptureSessionResource | undefined): S; + }>, +): DurableCaptureSessionBinding { + const ref = store.lookup(address); + let retained = slot.read(ref.session); + const requireSession = (): S => { + const session = store.resolveCurrent(ref); + if (session === undefined) throw new AppError('COMMAND_FAILED', 'Test session retired'); + return session; + }; + const assertAdoptable = (): void => { + if (slot.read(requireSession())) throw new AppError('COMMAND_FAILED', 'Test resource changed'); + }; + return Object.freeze({ + address, + sessionDir: store.resolveSessionDir(address), + read: () => { + const session = store.resolveCurrent(ref); + if (session !== undefined) retained = slot.read(session); + return retained; + }, + assertAdoptable, + canPersist: () => { + const session = store.resolveCurrent(ref); + return session !== undefined && slot.read(session) === undefined; + }, + adopt: (resource) => { + assertAdoptable(); + store.update(ref, (current) => slot.replace(current, resource)); + retained = resource; + }, + clear: (expected) => { + const current = store.resolveCurrent(ref); + if (current === undefined) return 'retired'; + const active = slot.read(current); + if ( + active?.handle !== expected.handle || + active.envelope.fence.token !== expected.envelope.fence.token || + active.envelope.fence.generation !== expected.envelope.fence.generation + ) + return 'resource-changed'; + store.update(ref, (session) => slot.replace(session, undefined)); + retained = undefined; + return 'cleared'; + }, + }); +} diff --git a/packages/capture-kit/src/durable-capture/transitions.test.ts b/packages/capture-kit/src/durable-capture/transitions.test.ts index 5085197ca1..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/host-kit/src/internal/owner-identity-liveness.test.ts b/packages/host-kit/src/internal/owner-identity-liveness.test.ts index fd5278afa8..511da64644 100644 --- a/packages/host-kit/src/internal/owner-identity-liveness.test.ts +++ b/packages/host-kit/src/internal/owner-identity-liveness.test.ts @@ -59,3 +59,13 @@ test('a missing entry in a completed process snapshot stays fail-closed without assert.equal(mockIsProcessZombie.mock.calls.length, 0); assert.equal(mockReadProcessStartTime.mock.calls.length, 0); }); + +test('a pid outside the native range is unknown without a liveness probe', () => { + assert.equal( + classifyOwnerLiveness({ owner: { pid: 2_147_483_648, startTime: 'start-a' } }), + 'unknown', + ); + assert.equal(mockIsProcessAlive.mock.calls.length, 0); + assert.equal(mockIsProcessZombie.mock.calls.length, 0); + assert.equal(mockReadProcessStartTime.mock.calls.length, 0); +}); diff --git a/packages/host-kit/src/internal/owner-identity.ts b/packages/host-kit/src/internal/owner-identity.ts index a7b1352b35..552a030ea7 100644 --- a/packages/host-kit/src/internal/owner-identity.ts +++ b/packages/host-kit/src/internal/owner-identity.ts @@ -11,6 +11,11 @@ export type OwnerIdentity = { startTime: string | null; }; +/** A process id accepted by Node's native signal API. */ +export function isProcessPid(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value > 0 && value <= 0x7fff_ffff; +} + export type OwnerLiveness = | 'live' | 'owner-process-dead' @@ -75,6 +80,7 @@ export function classifyOwnerLivenessFromObservation( observation?: HostProcessIdentityObservation | null, ): OwnerLiveness { const { owner, stateDir } = params; + if (!isProcessPid(owner.pid)) return 'unknown'; if (!isProcessAlive(owner.pid)) return 'owner-process-dead'; if (observation !== undefined ? observation?.state.startsWith('Z') : isProcessZombie(owner.pid)) { return 'owner-process-dead'; @@ -88,7 +94,10 @@ export function classifyOwnerLivenessFromObservation( return 'owner-process-reused'; } } - if (!stateDir) return 'live'; + return stateDir ? classifyOwnerStateDirectory(stateDir) : 'live'; +} + +function classifyOwnerStateDirectory(stateDir: string): OwnerLiveness { try { fs.statSync(stateDir); return 'live'; diff --git a/packages/host-kit/src/internal/process-lock.ts b/packages/host-kit/src/internal/process-lock.ts index 989b28a78d..3108c208e1 100644 --- a/packages/host-kit/src/internal/process-lock.ts +++ b/packages/host-kit/src/internal/process-lock.ts @@ -6,6 +6,7 @@ import { publishFileSync } from './atomic-file.ts'; import { emitDiagnostic } from './diagnostics.ts'; import { classifyOwnerLiveness, + isProcessPid, ownerIdentityMatches, type OwnerLiveness, } from './owner-identity.ts'; @@ -553,7 +554,7 @@ function parseProcessLockOwner(contents: string): ProcessLockOwnerRecord | null const PROCESS_LOCK_OWNER_FIELD_SHAPES: Record boolean> = { - pid: (value) => typeof value === 'number' && Number.isInteger(value) && value > 0, + pid: isProcessPid, acquiredAtMs: (value) => typeof value === 'number' && Number.isFinite(value), startTime: (value) => value === undefined || value === null || typeof value === 'string', }; diff --git a/packages/host-kit/src/process.ts b/packages/host-kit/src/process.ts index 2ed4c2c06d..6c061dd11f 100644 --- a/packages/host-kit/src/process.ts +++ b/packages/host-kit/src/process.ts @@ -36,6 +36,7 @@ export { export { classifyOwnerLiveness, classifyOwnerLivenessFromObservation, + isProcessPid, type OwnerIdentity, ownerIdentityDiffers, ownerIdentityMatches, diff --git a/packages/host-kit/src/session-paths.test.ts b/packages/host-kit/src/session-paths.test.ts new file mode 100644 index 0000000000..0b443d3452 --- /dev/null +++ b/packages/host-kit/src/session-paths.test.ts @@ -0,0 +1,18 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +// oxlint-disable-next-line no-restricted-imports -- asserts a path under os.homedir +import os from 'node:os'; +import path from 'node:path'; +import { expandSessionPath } from './session-paths.ts'; + +test('expandSessionPath resolves tilde, relative-with-cwd, and absolute paths', () => { + const homePath = expandSessionPath('~/flows/replay.ad'); + assert.equal(homePath, path.join(os.homedir(), 'flows', 'replay.ad')); + + const relativePath = expandSessionPath('workflows/replay.ad', '/tmp/agent-device-cwd'); + assert.equal(relativePath, path.resolve('/tmp/agent-device-cwd', 'workflows/replay.ad')); + + const absoluteInput = path.resolve('/tmp', 'agent-device-absolute.ad'); + const absolutePath = expandSessionPath(absoluteInput, '/tmp/ignored-cwd'); + assert.equal(absolutePath, absoluteInput); +}); diff --git a/packages/platform-android/src/recording/failed-finish.test.ts b/packages/platform-android/src/recording/failed-finish.test.ts index 4c4984020c..38f9be8955 100644 --- a/packages/platform-android/src/recording/failed-finish.test.ts +++ b/packages/platform-android/src/recording/failed-finish.test.ts @@ -12,8 +12,7 @@ import { adoptStartedDurableCapture, createDurableCaptureResourceStore, finishLiveDurableCapture, - type DurableCaptureResourceDefinition, - type DurableCaptureSessionStore, + type DurableCaptureRecordDefinition, } from '@agent-device/capture-kit/durable-capture'; import { mkdtempForTestSync } from '../__tests__/test-utils/tmp-dir.ts'; import { androidRecordingDevice, recordingHost, recordingInput } from './fixtures.ts'; @@ -114,7 +113,7 @@ type AndroidRecordingSession = Readonly<{ /** * The daemon's recording record assembled around the real Android handle: the same definition the - * daemon declares in `src/daemon/screen-recording-session-resource.ts`, including its policy, + * daemon declares in `packages/capture-kit/src/capture-admission/screen-recording-session-resource.ts`, including its policy, * driving the shared coordinator. */ async function adoptAndroidRecording(params: { @@ -128,42 +127,50 @@ async function adoptAndroidRecording(params: { fileName: 'screen-recording.resource.json', displayName: 'screen recording', }); - const definition: DurableCaptureResourceDefinition< - 'screen-recording', - ScreenRecordingLiveHandle, - ScreenRecordingCompletion, - AndroidRecordingSession - > = { - resourceKind: 'screen-recording', - displayName: 'screen recording', - store, - failedFinishPolicy: 'preserve-retry-material', - sessionSlot: { - read: (session) => session.recording, - replace: (session, recording) => ({ ...session, recording }), - }, - completionMetadata: (completion) => ({ outPath: completion.outPath }), - messages: { - noActive: 'no active recording', - cleanupPendingHint: 'Keep screen-recording.resource.json and retry stop.', - }, - }; + const definition: DurableCaptureRecordDefinition<'screen-recording', ScreenRecordingCompletion> = + { + resourceKind: 'screen-recording', + displayName: 'screen recording', + store, + failedFinishPolicy: 'preserve-retry-material', + completionMetadata: (completion) => ({ outPath: completion.outPath }), + messages: { + noActive: 'no active recording', + cleanupPendingHint: 'Keep screen-recording.resource.json and retry stop.', + }, + }; const sessionsDir = mkdtempForTestSync('agent-device-android-failed-finish-session-'); let session: AndroidRecordingSession = {}; - const sessionStore: DurableCaptureSessionStore = { - set: (_name, next) => { + const sessionStore = { + get: () => session, + set: (_name: string, next: AndroidRecordingSession) => { session = next; }, - resolveSessionDir: (name) => path.join(sessionsDir, name), + resolveSessionDir: (name: string) => path.join(sessionsDir, name), + }; + const binding = { + address: params.sessionName, + sessionDir: sessionStore.resolveSessionDir(params.sessionName), + read: () => session.recording, + assertAdoptable: () => { + if (session.recording) throw new Error('Already recording'); + }, + canPersist: () => !session.recording, + adopt: (recording: AndroidRecordingSession['recording']) => { + session = { ...session, recording }; + }, + clear: (expected: NonNullable) => { + if (session.recording?.handle !== expected.handle) return 'resource-changed' as const; + session = { ...session, recording: undefined }; + return 'cleared' as const; + }, }; - const resourcePath = store.resolvePath(sessionStore.resolveSessionDir(params.sessionName)); + const resourcePath = store.resolvePath(binding.sessionDir); await adoptStartedDurableCapture( definition, { reportUndurableCleanup: () => {}, - session, - sessionName: params.sessionName, - sessionStore, + binding, device: androidRecordingDevice, owner: params.owner, fence: params.envelope.fence, @@ -178,9 +185,7 @@ async function adoptAndroidRecording(params: { finishLiveDurableCapture( definition, { - session, - sessionName: params.sessionName, - sessionStore, + binding, intent: 'capture', }, resourcePath, diff --git a/scripts/layering/architecture-ownership.ts b/scripts/layering/architecture-ownership.ts index aa42c574f7..42704c05c6 100644 --- a/scripts/layering/architecture-ownership.ts +++ b/scripts/layering/architecture-ownership.ts @@ -73,6 +73,7 @@ const DAEMON_INTERACTION_FACADE = { exports: [ 'FindRouteInput', 'InteractionRouteInput', + 'bindInteractionSession', 'captureSnapshotForSession', 'createInteractionRuntime', 'finalizeTouchInteraction', diff --git a/scripts/layering/session-resource-ownership.test.ts b/scripts/layering/session-resource-ownership.test.ts index 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__/daemon-exit-wait.test.ts b/src/__tests__/daemon-exit-wait.test.ts index 2111bc2f3b..f2840761a1 100644 --- a/src/__tests__/daemon-exit-wait.test.ts +++ b/src/__tests__/daemon-exit-wait.test.ts @@ -57,6 +57,16 @@ afterEach(() => { vi.restoreAllMocks(); }); +test.each([0, -1, 1.5, 2_147_483_648, Number.MAX_SAFE_INTEGER])( + 'an invalid native pid %s cannot prove exit during recovery', + async (pid) => { + expect(await waitForDaemonExit({ pid, startTime: OURS }, { timeoutMs: 0 })).toEqual({ + exited: false, + elapsedMs: 0, + }); + }, +); + test('waitForDaemonExit reports a pid recycled mid-wait as exited, without burning the deadline', async () => { setTimeout(() => state.starts.set(PID, RECYCLED), 20); const wait = await waitForDaemonExit( diff --git a/src/__tests__/daemon-process-takeover.test.ts b/src/__tests__/daemon-process-takeover.test.ts index 78156ffc64..99292988f6 100644 --- a/src/__tests__/daemon-process-takeover.test.ts +++ b/src/__tests__/daemon-process-takeover.test.ts @@ -12,6 +12,19 @@ const TAKEOVER_TIMEOUTS = { termTimeoutMs: 5_000, killTimeoutMs: 2_000 }; const spawnedChildren: { child: ChildProcess; exited: Promise }[] = []; const spawnedRoots: string[] = []; +test.each([0, -1, 1.5, Number.NaN, '123', 2_147_483_648, Number.MAX_SAFE_INTEGER])( + 'an invalid daemon pid %s cannot prove exit', + async (pid) => { + assert.deepEqual( + await stopDaemonProcess( + { pid: pid as number, startTime: 'captured-birth' }, + { mode: 'force', termTimeoutMs: 0, killTimeoutMs: 0 }, + ), + { status: 'retained', reason: 'identity-unverified' }, + ); + }, +); + afterEach(async () => { for (const { child, exited } of spawnedChildren.splice(0)) { if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); diff --git a/src/__tests__/daemon-registration-owner.test.ts b/src/__tests__/daemon-registration-owner.test.ts index edb42abced..5426a07764 100644 --- a/src/__tests__/daemon-registration-owner.test.ts +++ b/src/__tests__/daemon-registration-owner.test.ts @@ -1,16 +1,33 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; +import path from 'node:path'; import { afterEach, test, vi } from 'vitest'; -import { readCurrentOwnerIdentity } from '@agent-device/host-kit/process'; +import { readCurrentOwnerIdentity, isProcessAlive } from '@agent-device/host-kit/process'; import { tryAcquireDaemonRegistration, stopAndRetireDaemon, recoverAbandonedDaemonRegistration, + createOwnedReplayStateDir, + DAEMON_STARTUP_EXIT_CODES, + launchDaemonProcess, + type OwnedReplayStateDir, } from '../daemon-registration-owner.ts'; import { resolveDaemonPaths, type DaemonPaths } from '../daemon-resolution.ts'; import { readRegisteredDaemonOwnership } from '../daemon-registration.ts'; import { readDaemonShutdownReport } from '../daemon-shutdown-report.ts'; import { mkdtempForTestSync } from './test-utils/tmp-dir.ts'; +import { + registeredDaemonFixtureArgs, + spawnRegisteredDaemonFixture, + finishRegisteredDaemonFixture, +} from './test-utils/registered-daemon-fixture.ts'; +import { spawnLegacyDaemonFixture } from './test-utils/legacy-daemon-fixture.ts'; +import { ensureDaemon, resolveClientSettings } from '../daemon-client/daemon-client-lifecycle.ts'; +import { inspectProcessLock } from '@agent-device/host-kit/file'; +import { AppError } from '@agent-device/kernel/errors'; +import { sleep } from '@agent-device/host-kit/retry'; +import { stopDaemonProcess } from '../daemon-process.ts'; +import { stopDaemon } from '../daemon/daemon-stop.ts'; const fields = { socketPort: 4210, @@ -305,3 +322,306 @@ test.skipIf(process.getuid?.() === 0)( } }, ); + +test('a forged private-directory capability cannot authorize even matching dead metadata removal', async () => { + const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-private-forgery-')); + replaceInfo(paths, deadIdentity.pid, deadIdentity.startTime); + const before = fs.readFileSync(paths.infoPath, 'utf8'); + const result = await stopAndRetireDaemon({ + paths, + observed: deadIdentity, + mode: 'force', + ownedStateDir: Object.freeze({ paths }) as OwnedReplayStateDir, + }); + assert.equal(result.status, 'retained'); + assert.equal(fs.readFileSync(paths.infoPath, 'utf8'), before); +}); + +test('private retirement closes startup admission, joins the actual child and never recreates a removed directory', async () => { + const ownedStateDir = createOwnedReplayStateDir(); + const paths = ownedStateDir.paths; + const args = registeredDaemonFixtureArgs(paths, fields); + const launch = launchDaemonProcess({ paths, args, serverMode: 'socket', ownedStateDir }); + let contender: ReturnType | undefined; + try { + assert.ok(launch.startTime); + await waitForFixtureFile(paths.infoPath); + contender = launchDaemonProcess({ paths, args, serverMode: 'socket', ownedStateDir }); + assert.equal((await contender.exited).exitCode, DAEMON_STARTUP_EXIT_CODES.busy); + const input = { + paths, + observed: { pid: launch.pid, startTime: launch.startTime }, + mode: 'graceful' as const, + ownedStateDir, + }; + const pending = stopAndRetireDaemon(input); + assert.throws( + () => launchDaemonProcess({ paths, args, serverMode: 'socket', ownedStateDir }), + (error: { details?: { reason?: string } }) => + error.details?.reason === 'daemon_startup_admission_closed', + ); + const result = await pending; + assert.equal(result.status, 'retired', JSON.stringify(result)); + if (result.status !== 'retired') assert.fail('retirement not confirmed'); + assert.equal(result.removedStateDir, true); + assert.equal((await launch.exited).exitCode, 0); + assert.equal(fs.existsSync(paths.baseDir), false); + assert.deepEqual(await stopAndRetireDaemon(input), result); + assert.equal(fs.existsSync(paths.baseDir), false); + } finally { + await finishPrivateTestDaemons(paths, launch, contender); + } +}); + +test('private retirement retains the directory while an earlier actual startup child is still paused', async () => { + const ownedStateDir = createOwnedReplayStateDir(); + const paths = ownedStateDir.paths; + const args = registeredDaemonFixtureArgs(paths, fields); + const entry = args[1]!; + const ready = `${paths.baseDir}/paused-startup.ready`; + fs.writeFileSync( + entry, + `import fs from 'node:fs'; fs.writeFileSync(${JSON.stringify(ready)}, 'ready'); setInterval(() => {}, 1000);`, + ); + const first = launchDaemonProcess({ paths, args, serverMode: 'socket', ownedStateDir }); + let second: ReturnType | undefined; + try { + await waitForFixtureFile(ready); + second = launchDaemonProcess({ + paths, + args: registeredDaemonFixtureArgs(paths, fields), + serverMode: 'socket', + ownedStateDir, + }); + await waitForFixtureFile(paths.infoPath); + const result = await stopAndRetireDaemon({ + paths, + observed: { pid: second.pid, startTime: second.startTime ?? null }, + mode: 'graceful', + ownedStateDir, + startupJoinTimeoutMs: 0, + }); + assert.equal(result.status, 'retained', JSON.stringify(result)); + if (result.status !== 'retained') assert.fail('private state unexpectedly retired'); + assert.equal(result.reason, 'startup-unconfirmed'); + assert.equal(result.termination?.status, 'exited'); + assert.equal(fs.existsSync(paths.baseDir), true); + assert.equal(isProcessAlive(first.pid), true); + assert.equal((await second.exited).exitCode, 0); + } finally { + await finishPrivateTestDaemons(paths, first, second); + } +}); + +for (const contents of [ + '{broken', + '{"owner":"default","expiresAt":1e400}', + '{"owner":"default","expiresAt":-1e400}', + ...[false, null, 0, {}].map((commitFailure) => + JSON.stringify({ owner: 'default', expiresAt: Date.now() + 60_000, commitFailure }), + ), +]) { + test(`malformed repair evidence (${contents}) retains private state after the actual child has exited`, async () => { + const ownedStateDir = createOwnedReplayStateDir(); + const paths = ownedStateDir.paths; + const sessionDir = `${paths.sessionsDir}/default`; + fs.mkdirSync(sessionDir, { recursive: true }); + const evidencePath = `${sessionDir}/repair-tombstone.json`; + fs.writeFileSync(evidencePath, contents); + const launch = launchDaemonProcess({ + paths, + args: registeredDaemonFixtureArgs(paths, fields), + serverMode: 'socket', + ownedStateDir, + }); + try { + await waitForFixtureFile(paths.infoPath); + const result = await stopAndRetireDaemon({ + paths, + observed: { pid: launch.pid, startTime: launch.startTime ?? null }, + mode: 'graceful', + ownedStateDir, + }); + assert.equal(result.status, 'retained', JSON.stringify(result)); + if (result.status !== 'retained') assert.fail('repair evidence unexpectedly discarded'); + assert.equal(result.termination?.status, 'exited'); + assert.equal(result.error?.details?.reason, 'repair_evidence_invalid'); + assert.equal(fs.readFileSync(evidencePath, 'utf8'), contents); + await launch.exited; + } finally { + await finishPrivateTestDaemons(paths, launch); + } + }); +} + +async function waitForFixtureFile(filePath: string): Promise { + const deadline = Date.now() + 2_000; + while (!fs.existsSync(filePath) && Date.now() < deadline) await sleep(20); + assert.equal(fs.existsSync(filePath), true); +} + +async function finishPrivateTestDaemons( + paths: DaemonPaths, + ...launches: (ReturnType | undefined)[] +): Promise { + for (const launch of launches) { + if (!launch) continue; + const termination = await stopDaemonProcess( + { pid: launch.pid, startTime: launch.startTime ?? null }, + { mode: 'force', termTimeoutMs: 0, killTimeoutMs: 2_000 }, + ); + assert.notEqual(termination.status, 'retained', JSON.stringify(termination)); + await launch.exited; + } + fs.rmSync(paths.baseDir, { recursive: true, force: true }); +} + +async function waitForCutoverFixture(ready: () => boolean): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + if (ready()) return; + await sleep(10); + } + assert.fail('cutover fixture did not reach its barrier'); +} + +function legacyDisposition(paths: DaemonPaths): boolean { + return ( + JSON.parse(fs.readFileSync(path.join(paths.baseDir, 'legacy-disposition.json'), 'utf8')) as { + acquired: boolean; + } + ).acquired; +} + +test('cutover refuses an already-running legacy daemon before signaling or changing registration', async () => { + const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-old-daemon-')); + const legacy = spawnLegacyDaemonFixture(paths); + try { + await waitForCutoverFixture(() => fs.existsSync(paths.infoPath)); + const metadata = fs.readFileSync(paths.infoPath, 'utf8'); + const lock = fs.readFileSync(paths.lockPath, 'utf8'); + const contender = spawnRegisteredDaemonFixture(paths, fields, undefined); + let disposition: Awaited | undefined; + void contender.exited.then((result) => { + disposition = result; + }); + await waitForCutoverFixture( + () => Boolean(disposition) || fs.existsSync(path.join(paths.baseDir, 'registration-held')), + ); + assert.ok(disposition, 'a new daemon must refuse an occupied legacy file'); + assert.equal(disposition.exitCode, DAEMON_STARTUP_EXIT_CODES.unproven); + await assert.rejects( + ensureDaemon( + resolveClientSettings({ + session: 'default', + command: 'devices', + positionals: [], + flags: { stateDir: paths.baseDir }, + }), + ), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.reason, 'daemon_registration_unproven'); + return true; + }, + ); + assert.equal(process.kill(legacy.pid, 0), true); + assert.equal(fs.readFileSync(paths.infoPath, 'utf8'), metadata); + assert.equal(fs.readFileSync(paths.lockPath, 'utf8'), lock); + } finally { + await legacy.stop(); + await finishRegisteredDaemonFixture(paths.baseDir); + } +}); + +test('a legacy contender cannot unlink a hardened owner while it delays metadata publication', async () => { + const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-new-daemon-')); + const deferred = path.join(paths.baseDir, 'defer-publication'); + fs.writeFileSync(deferred, 'wait'); + const current = spawnRegisteredDaemonFixture(paths, fields, undefined); + let legacy: ReturnType | undefined; + try { + await waitForCutoverFixture(() => fs.existsSync(path.join(paths.baseDir, 'registration-held'))); + legacy = spawnLegacyDaemonFixture(paths); + await waitForCutoverFixture(() => + fs.existsSync(path.join(paths.baseDir, 'legacy-disposition.json')), + ); + assert.equal(legacyDisposition(paths), false); + await legacy.exited; + const claim = inspectProcessLock(paths.lockPath); + assert.equal(claim.state, 'held'); + if (claim.state !== 'held') throw new Error('current owner lost its claim'); + assert.equal(claim.owner.pid, current.pid); + assert.equal(process.kill(current.pid, 0), true); + assert.equal(fs.existsSync(paths.infoPath), false); + fs.unlinkSync(deferred); + await waitForCutoverFixture(() => fs.existsSync(paths.infoPath)); + assert.equal(JSON.parse(fs.readFileSync(paths.infoPath, 'utf8')).pid, current.pid); + } finally { + await legacy?.stop(); + await finishRegisteredDaemonFixture(paths.baseDir); + } +}); + +test('concurrent old and new daemon startup has one owner at the shared lock path', async () => { + const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-cutover-race-')); + const barrier = path.join(paths.baseDir, 'start'); + const legacy = spawnLegacyDaemonFixture(paths, barrier); + const current = spawnRegisteredDaemonFixture(paths, fields, undefined, barrier); + let currentExited = false; + void current.exited.then(() => { + currentExited = true; + }); + try { + await waitForCutoverFixture( + () => + fs.existsSync(`${barrier}.ready-${legacy.pid}`) && + fs.existsSync(`${barrier}.ready-${current.pid}`), + ); + fs.writeFileSync(barrier, 'start'); + await waitForCutoverFixture( + () => + fs.existsSync(path.join(paths.baseDir, 'legacy-disposition.json')) && + (currentExited || fs.existsSync(path.join(paths.baseDir, 'registration-held'))), + ); + const oldAcquired = legacyDisposition(paths); + const claim = inspectProcessLock(paths.lockPath); + const newAcquired = fs.existsSync(path.join(paths.baseDir, 'registration-held')); + assert.equal(Number(oldAcquired) + Number(newAcquired), 1); + if (newAcquired) { + assert.ok(claim.state === 'held'); + assert.equal(claim.owner.pid, current.pid); + } + if (oldAcquired) + assert.equal((await current.exited).exitCode, DAEMON_STARTUP_EXIT_CODES.unproven); + else await legacy.exited; + await waitForCutoverFixture(() => fs.existsSync(paths.infoPath)); + assert.equal( + JSON.parse(fs.readFileSync(paths.infoPath, 'utf8')).pid, + newAcquired ? current.pid : legacy.pid, + ); + } finally { + await legacy.stop(); + await finishRegisteredDaemonFixture(paths.baseDir); + } +}); + +for (const mode of ['graceful', 'forced'] as const) { + test(`manual ${mode} stop awaits actual child exit and protected registration retirement`, async () => { + const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-manual-stop-')); + if (mode === 'forced') fs.writeFileSync(path.join(paths.baseDir, 'ignore-sigterm'), 'hold'); + const child = spawnRegisteredDaemonFixture(paths, fields, undefined); + try { + await waitForFixtureFile(paths.infoPath); + const result = await stopDaemon({ paths, graceTimeoutMs: 30, killTimeoutMs: 1_000 }); + assert.equal(result.stopped, true); + assert.equal(result.mode, mode); + assert.equal(result.cleanupConfidence, mode === 'forced' ? 'unknown' : 'known'); + await child.exited; + assert.equal(fs.existsSync(paths.infoPath), false); + assert.equal(fs.existsSync(paths.lockPath), false); + assert.equal(fs.existsSync(paths.baseDir), true); + } finally { + await finishRegisteredDaemonFixture(paths.baseDir); + } + }); +} diff --git a/src/__tests__/test-utils/daemon-http-fixture.ts b/src/__tests__/test-utils/daemon-http-fixture.ts index 4712a049c6..485bba4e7e 100644 --- a/src/__tests__/test-utils/daemon-http-fixture.ts +++ b/src/__tests__/test-utils/daemon-http-fixture.ts @@ -16,6 +16,7 @@ export type HttpDaemonFixture = { export async function startHttpDaemonFixture( responseData: Record, + options: { ready?: () => boolean } = {}, ): Promise { const seenPaths: string[] = []; const rpcRequests: Record[] = []; @@ -24,7 +25,7 @@ export async function startHttpDaemonFixture( seenPaths.push(`${req.method ?? 'GET'} ${url.pathname}`); if (req.method === 'GET' && url.pathname === '/health') { - res.writeHead(200); + res.writeHead(options.ready?.() === false ? 503 : 200); res.end('ok'); return; } diff --git a/src/__tests__/test-utils/legacy-daemon-fixture.ts b/src/__tests__/test-utils/legacy-daemon-fixture.ts new file mode 100644 index 0000000000..b5b40ff06a --- /dev/null +++ b/src/__tests__/test-utils/legacy-daemon-fixture.ts @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { runCmdDetachedMonitored } from '@agent-device/host-kit/command'; +import { readProcessStartTime } from '@agent-device/host-kit/process'; +import { stopDaemonProcess } from '../../daemon-process.ts'; +import type { DaemonPaths } from '../../daemon-resolution.ts'; + +// c237027737: server-lifecycle.ts readLockInfo/acquireDaemonLock/releaseDaemonLock. +const legacyLockProtocol = ` +function readLockInfo(lockPath) { + if (!fs.existsSync(lockPath)) return null; + try { + const parsed = JSON.parse(fs.readFileSync(lockPath, 'utf8')); + if (!Number.isInteger(parsed.pid) || parsed.pid <= 0) return null; + return parsed; + } catch { + return null; + } +} +function acquireDaemonLock(baseDir, lockPath, lockData) { + if (!fs.existsSync(baseDir)) fs.mkdirSync(baseDir, { recursive: true }); + const payload = JSON.stringify(lockData, null, 2); + const tryWriteLock = () => { + try { + fs.writeFileSync(lockPath, payload, { flag: 'wx', mode: 0o600 }); + return true; + } catch (error) { + if (error.code === 'EEXIST') return false; + throw error; + } + }; + if (tryWriteLock()) return true; + const existing = readLockInfo(lockPath); + if (existing?.pid && existing.pid !== process.pid && + isAgentDeviceDaemonProcess(existing.pid, existing.processStartTime)) return false; + try { fs.unlinkSync(lockPath); } catch {} + return tryWriteLock(); +} +function releaseDaemonLock(lockPath) { + const existing = readLockInfo(lockPath); + if (existing && existing.pid !== process.pid) return; + try { if (fs.existsSync(lockPath)) fs.unlinkSync(lockPath); } catch {} +} +`; + +export function spawnLegacyDaemonFixture(paths: DaemonPaths, acquisitionBarrier?: string) { + const codeDir = path.join(paths.baseDir, 'legacy'); + const entry = path.join(codeDir, 'dist', 'src', 'internal', 'daemon.js'); + fs.mkdirSync(path.dirname(entry), { recursive: true }); + fs.writeFileSync(path.join(codeDir, 'package.json'), '{"type":"module"}'); + const processUrl = new URL('../../daemon-process.ts', import.meta.url).href; + const hostProcessUrl = new URL('../../../packages/host-kit/src/process.ts', import.meta.url).href; + fs.writeFileSync( + entry, + ` +import fs from 'node:fs'; +import { isAgentDeviceDaemonProcess } from ${JSON.stringify(processUrl)}; +import { readProcessStartTime } from ${JSON.stringify(hostProcessUrl)}; +${legacyLockProtocol} +const paths = ${JSON.stringify(paths)}; +const barrier = ${JSON.stringify(acquisitionBarrier)}; +if (barrier) { + fs.writeFileSync(barrier + '.ready-' + process.pid, 'ready'); + while (!fs.existsSync(barrier)) await new Promise(resolve => setTimeout(resolve, 10)); +} +const identity = { pid: process.pid, processStartTime: readProcessStartTime(process.pid) }; +const acquired = acquireDaemonLock(paths.baseDir, paths.lockPath, { + ...identity, version: '0.21.20', startedAt: Date.now(), +}); +fs.writeFileSync(paths.baseDir + '/legacy-disposition.json', JSON.stringify({ acquired })); +if (!acquired) process.exit(0); +fs.writeFileSync(paths.infoPath, JSON.stringify({ ...identity, port: 4210, token: 'legacy-token', version: '0.21.20' })); +process.on('SIGTERM', () => { releaseDaemonLock(paths.lockPath); process.exit(0); }); +setInterval(() => {}, 1000); +`, + ); + const child = runCmdDetachedMonitored(process.execPath, ['--experimental-strip-types', entry]); + const startTime = readProcessStartTime(child.pid); + return { + pid: child.pid, + exited: child.exited, + async stop() { + const result = await stopDaemonProcess( + { pid: child.pid, startTime }, + { mode: 'force', termTimeoutMs: 0, killTimeoutMs: 2_000 }, + ); + assert.notEqual(result.status, 'retained', JSON.stringify(result)); + await child.exited; + }, + }; +} diff --git a/src/__tests__/test-utils/registered-daemon-fixture.test.ts b/src/__tests__/test-utils/registered-daemon-fixture.test.ts new file mode 100644 index 0000000000..e4e265f4af --- /dev/null +++ b/src/__tests__/test-utils/registered-daemon-fixture.test.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { test } from 'vitest'; +import { resolveDaemonPaths } from '../../daemon-resolution.ts'; +import { stopDaemonProcess } from '../../daemon-process.ts'; +import { mkdtempForTestSync } from './tmp-dir.ts'; +import { + spawnRegisteredDaemonFixture, + waitForRegisteredDaemonFixture, + finishRegisteredDaemonFixture, +} from './registered-daemon-fixture.ts'; + +test('a joined fixture exit refuses its remaining registration metadata', async () => { + const paths = resolveDaemonPaths(mkdtempForTestSync('daemon-fixture-exited-publication-')); + const child = spawnRegisteredDaemonFixture( + paths, + { + httpPort: 4210, + token: 'fixture', + version: 'test', + codeOrigin: 'checkout', + codeSignature: 'fixture', + }, + undefined, + ); + try { + const observed = await waitForRegisteredDaemonFixture(paths, child); + assert.equal( + ( + await stopDaemonProcess( + { pid: child.pid, startTime: observed.processStartTime ?? null }, + { mode: 'force', termTimeoutMs: 0, killTimeoutMs: 1_000 }, + ) + ).status, + 'exited', + ); + await child.exited; + assert.equal(fs.existsSync(paths.infoPath), true); + await assert.rejects(waitForRegisteredDaemonFixture(paths, child), /exited before publication/); + } finally { + await finishRegisteredDaemonFixture(paths.baseDir); + } +}); diff --git a/src/__tests__/test-utils/registered-daemon-fixture.ts b/src/__tests__/test-utils/registered-daemon-fixture.ts new file mode 100644 index 0000000000..9131617410 --- /dev/null +++ b/src/__tests__/test-utils/registered-daemon-fixture.ts @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { vi } from 'vitest'; +import type { runCmdDetachedMonitored, ExecDetachedExit } from '@agent-device/host-kit/command'; +import { readDaemonInfo, type DaemonInfo } from '../../daemon-client/daemon-client-metadata.ts'; +import { readProcessStartTime } from '@agent-device/host-kit/process'; +import { stopDaemonProcess } from '../../daemon-process.ts'; +import type { DaemonPaths } from '../../daemon-resolution.ts'; +import type { DaemonRegistrationFields } from '../../daemon-registration-owner.ts'; + +const actualCommand = await vi.importActual( + '@agent-device/host-kit/command', +); +const children = new Map< + string, + Array<{ launch: ReturnType; startTime: string | null }> +>(); + +/** A real registration owner, advertising the caller's HTTP fixture and joining before deletion. */ +export function registeredDaemonFixtureArgs( + paths: DaemonPaths, + fields: DaemonRegistrationFields, + acquisitionBarrier?: string, +): string[] { + const entry = path.join(paths.baseDir, 'dist', 'src', 'internal', 'daemon.js'); + fs.mkdirSync(path.dirname(entry), { recursive: true }); + fs.writeFileSync(path.join(paths.baseDir, 'package.json'), '{"type":"module"}'); + const registrationUrl = new URL('../../daemon-registration-owner.ts', import.meta.url).href; + fs.writeFileSync( + entry, + `import fs from 'node:fs'; +import path from 'node:path'; +import { DAEMON_STARTUP_EXIT_CODES, tryAcquireDaemonRegistration } from ${JSON.stringify(registrationUrl)}; +const paths = ${JSON.stringify(paths)}; +const barrier = ${JSON.stringify(acquisitionBarrier)}; +if (barrier) { + fs.writeFileSync(barrier + '.ready-' + process.pid, 'ready'); + while (!fs.existsSync(barrier)) await new Promise(resolve => setTimeout(resolve, 10)); +} +const acquired = await tryAcquireDaemonRegistration(paths); +if (acquired.status !== 'acquired') process.exit(DAEMON_STARTUP_EXIT_CODES[acquired.status]); +process.on('SIGTERM', async () => { + if (fs.existsSync(path.join(paths.baseDir, 'ignore-sigterm'))) return; + const deferred = path.join(paths.baseDir, 'repair-on-shutdown.json'); + if (fs.existsSync(deferred)) { + const dir = path.join(paths.sessionsDir, 'default'); + fs.mkdirSync(dir, { recursive: true }); + fs.copyFileSync(deferred, path.join(dir, 'repair-tombstone.json')); + } + await acquired.owner.finish(); + process.exit(0); +}); +fs.writeFileSync(path.join(paths.baseDir, 'registration-held'), 'ready'); +while (fs.existsSync(path.join(paths.baseDir, 'defer-publication'))) await new Promise(resolve => setTimeout(resolve, 10)); +acquired.owner.publish(${JSON.stringify(fields)}); +setInterval(() => {}, 1000); +`, + ); + return ['--experimental-strip-types', entry]; +} + +export function spawnRegisteredDaemonFixture( + paths: DaemonPaths, + fields: DaemonRegistrationFields, + options: Parameters[2], + acquisitionBarrier?: string, +): ReturnType { + const child = actualCommand.runCmdDetachedMonitored( + process.execPath, + registeredDaemonFixtureArgs(paths, fields, acquisitionBarrier), + options, + ); + const owned = children.get(paths.baseDir) ?? []; + owned.push({ launch: child, startTime: readProcessStartTime(child.pid) }); + children.set(paths.baseDir, owned); + return child; +} + +export async function waitForRegisteredDaemonFixture( + paths: DaemonPaths, + child: ReturnType, +): Promise { + let exit: ExecDetachedExit | undefined; + void child.exited.then((result) => { + exit = result; + }); + await Promise.resolve(); + for (let attempt = 0; attempt < 400; attempt += 1) { + if (exit) + throw new Error(`Registered child exited before publication: ${JSON.stringify(exit)}`); + const info = readDaemonInfo(paths.infoPath); + if (info?.pid === child.pid) return info; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`Registered child ${child.pid} did not publish ${paths.infoPath} within 4s`); +} + +export async function finishRegisteredDaemonFixture(stateDir: string): Promise { + for (const owned of children.get(stateDir) ?? []) { + const child = owned.launch; + const termination = await stopDaemonProcess( + { pid: child.pid, startTime: owned.startTime }, + { mode: 'force', termTimeoutMs: 0, killTimeoutMs: 2_000 }, + ); + assert.notEqual(termination.status, 'retained', JSON.stringify(termination)); + await child.exited; + } + children.delete(stateDir); + fs.rmSync(stateDir, { recursive: true, force: true }); +} + +export async function finishRegisteredDaemonFixtures(): Promise { + for (const stateDir of children.keys()) await finishRegisteredDaemonFixture(stateDir); +} diff --git a/src/__tests__/test-utils/store-factory.ts b/src/__tests__/test-utils/store-factory.ts index 6fccc173af..cc5a128879 100644 --- a/src/__tests__/test-utils/store-factory.ts +++ b/src/__tests__/test-utils/store-factory.ts @@ -1,8 +1,20 @@ import path from 'node:path'; import { SessionStore } from '../../daemon/session-store.ts'; import { mkdtempForTestSync } from './tmp-dir.ts'; +import type { SessionRef, SessionState } from '../../daemon/session-state.ts'; export function makeSessionStore(prefix = 'agent-device-test-'): SessionStore { const tempRoot = mkdtempForTestSync(prefix); return new SessionStore(path.join(tempRoot, 'sessions')); } + +export function makeStoredSessionRef(session: SessionState, address = session.name): SessionRef { + return makeSessionStore().publish(address, session); +} + +export function storeSessionForTest(store: SessionStore, session: SessionState): SessionRef { + const ref = store.lookup(session.name); + if (!ref) return store.publish(session.name, session); + if (ref.session !== session) throw new Error('A different test session occupies this address'); + return ref; +} diff --git a/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts b/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts index 91f4da210a..9c67e88a31 100644 --- a/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts +++ b/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts @@ -6,6 +6,12 @@ import net from 'node:net'; import path from 'node:path'; import { afterEach, test, vi } from 'vitest'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { + spawnRegisteredDaemonFixture, + waitForRegisteredDaemonFixture, + finishRegisteredDaemonFixture, + finishRegisteredDaemonFixtures, +} from '../../__tests__/test-utils/registered-daemon-fixture.ts'; vi.mock('@agent-device/host-kit/command', async (importOriginal) => ({ ...(await importOriginal()), @@ -22,6 +28,7 @@ import { resolveDaemonPaths, type DaemonPaths } from '../../daemon-resolution.ts import { sendToDaemon, type DaemonRequest, type DaemonResponse } from '../daemon-client.ts'; import { attachActiveSessionAddressHint } from '../daemon-client-lifecycle.ts'; import { sendRequest } from '../daemon-client-transport.ts'; +import type { DaemonRetirementResult } from '../../daemon-registration-owner.ts'; import { closeLoopbackServer, listenOnLoopback, @@ -34,6 +41,7 @@ import { currentDaemonCodeSignature, } from '../../__tests__/test-utils/daemon-http-fixture.ts'; import { AppError } from '@agent-device/kernel/errors'; +import { tryAcquireProcessLock, inspectProcessLock } from '@agent-device/host-kit/file'; import { runCmdDetachedMonitored, runCmdSync } from '@agent-device/host-kit/command'; import { shellQuoteIfNeeded } from '@agent-device/kernel/device-shell'; import { readProcessStartTime } from '@agent-device/host-kit/process'; @@ -51,14 +59,19 @@ type DaemonInfoFixture = { processStartTime?: string; }; +const actualRetry = await vi.importActual( + '@agent-device/host-kit/retry', +); const mockRunCmdDetached = vi.mocked(runCmdDetachedMonitored); const mockRunCmdSync = vi.mocked(runCmdSync); const mockSleep = vi.mocked(sleep); -afterEach(() => { +afterEach(async () => { + await finishRegisteredDaemonFixtures(); mockRunCmdDetached.mockReset(); mockRunCmdSync.mockClear(); - mockSleep.mockClear(); + mockSleep.mockReset(); + mockSleep.mockImplementation(async () => {}); vi.unstubAllEnvs(); }); @@ -147,16 +160,22 @@ function installSpawnedHttpDaemonAtOwnedStateDir( httpPort: number, onStateDir: (stateDir: string) => void, ): void { + mockSleep.mockImplementation(actualRetry.sleep); mockRunCmdDetached.mockImplementation((_command, _args, options) => { const ownedStateDir = String(options?.env?.AGENT_DEVICE_STATE_DIR); onStateDir(ownedStateDir); const ownedPaths = resolveDaemonPaths(ownedStateDir); - writeDaemonInfo(ownedPaths, { httpPort, transport: 'http' }); - writeDaemonLock(ownedPaths, { - pid: process.pid, - processStartTime: readProcessStartTime(process.pid) ?? undefined, - }); - return { pid: process.pid, exited: new Promise(() => {}) }; + return spawnRegisteredDaemonFixture( + ownedPaths, + { + httpPort, + token: 'local-secret', + version: readVersion(), + codeOrigin: 'checkout', + codeSignature: currentDaemonCodeSignature(), + }, + options, + ); }); } @@ -192,14 +211,20 @@ async function startHangingHttpDaemonFixture(): Promise { } function installSpawnedHttpDaemon(paths: DaemonPaths, httpPort: number): void { + mockSleep.mockImplementation(actualRetry.sleep); mockRunCmdDetached.mockImplementation((_command, _args, options) => { assert.equal(options?.env?.AGENT_DEVICE_STATE_DIR, paths.baseDir); - writeDaemonInfo(paths, { httpPort, transport: 'http' }); - writeDaemonLock(paths, { - pid: process.pid, - processStartTime: readProcessStartTime(process.pid) ?? undefined, - }); - return { pid: process.pid, exited: new Promise(() => {}) }; + return spawnRegisteredDaemonFixture( + paths, + { + httpPort, + token: 'local-secret', + version: readVersion(), + codeOrigin: 'checkout', + codeSignature: currentDaemonCodeSignature(), + }, + options, + ); }); } @@ -298,7 +323,7 @@ function mockSocketErrorAfterWrite(failingPort: number): { }; } -test('sendToDaemon retries daemon spawn failures and cleans partial metadata on terminal failure', async () => { +test('sendToDaemon retains unknown metadata after a spawn failure', async () => { const stateDir = makeTempStateDir('agent-device-daemon-spawn-retry-'); const paths = resolveDaemonPaths(stateDir); vi.stubEnv('AGENT_DEVICE_STATE_DIR', stateDir); @@ -329,27 +354,19 @@ test('sendToDaemon retries daemon spawn failures and cleans partial metadata on assert.ok(thrown instanceof AppError); assert.equal(thrown.message, 'Failed to start daemon'); - assert.equal(thrown.details?.startError, 'spawn failed 2'); - assert.equal(thrown.details?.startupAttempts, 2); - const cleanupResults = thrown.details?.cleanupResults; - assert.ok(Array.isArray(cleanupResults)); - assert.deepEqual( - cleanupResults.map((result) => ({ - reason: result.reason, - removedInfo: result.removedInfo, - removedLock: result.removedLock, - })), - [ - { reason: 'start_error', removedInfo: true, removedLock: true }, - { reason: 'start_error', removedInfo: true, removedLock: true }, - ], - ); - assert.equal(attempts, 2); - assert.equal(mockSleep.mock.calls[0]?.[0], 150); - assert.equal(fs.existsSync(paths.infoPath), false); - assert.equal(fs.existsSync(paths.lockPath), false); + assert.equal(fs.readFileSync(paths.infoPath, 'utf8'), '{"partial":true}\n'); + assert.equal(fs.readFileSync(paths.lockPath, 'utf8'), 'not-json\n'); + assert.equal(thrown.details?.startError, 'spawn failed 1'); + assert.equal(thrown.details?.startupAttempts, 1); + const results = thrown.details?.cleanupResults as Array<{ + status: string; + removedInfo: boolean; + }>; + assert.equal(results[0]?.status, 'retained'); + assert.equal(results[0]?.removedInfo, false); + assert.equal(attempts, 1); } finally { - fs.rmSync(stateDir, { recursive: true, force: true }); + await finishRegisteredDaemonFixture(stateDir); } }); @@ -394,11 +411,11 @@ test('sendToDaemon reports early daemon exit with log tail and startup paths', a assert.match(String(thrown.details?.daemonLogTail), /early daemon failure 2/); assert.equal(attempts, 2); } finally { - fs.rmSync(stateDir, { recursive: true, force: true }); + await finishRegisteredDaemonFixture(stateDir); } }); -test('sendToDaemon removes stale daemon lock before spawning a fresh daemon', async (t) => { +test('daemon acquisition reclaims a proven reused owner before publication', async (t) => { if (!(await supportsLoopbackBind())) { t.skip('loopback listeners are not permitted in this environment'); return; @@ -408,10 +425,11 @@ test('sendToDaemon removes stale daemon lock before spawning a fresh daemon', as const paths = resolveDaemonPaths(stateDir); const daemon = await startHttpDaemonFixture({ via: 'fresh-daemon' }); vi.stubEnv('AGENT_DEVICE_STATE_DIR', stateDir); - writeDaemonLock(paths, { - pid: process.pid, - processStartTime: 'stale-start-time', + const stale = tryAcquireProcessLock({ + lockDirPath: paths.lockPath, + owner: { pid: process.pid, startTime: 'stale-start-time', acquiredAtMs: Date.now() }, }); + assert.equal(stale.status, 'acquired'); installSpawnedHttpDaemon(paths, daemon.port); try { @@ -423,18 +441,16 @@ test('sendToDaemon removes stale daemon lock before spawning a fresh daemon', as meta: { requestId: 'req-stale-lock' }, }); - const freshLock = JSON.parse(fs.readFileSync(paths.lockPath, 'utf8')) as { - pid?: number; - processStartTime?: string; - }; + const freshLock = inspectProcessLock(paths.lockPath); assert.deepEqual(response, { ok: true, data: { via: 'fresh-daemon' } }); assert.equal(mockRunCmdDetached.mock.calls.length, 1); - assert.equal(freshLock.pid, process.pid); - assert.notEqual(freshLock.processStartTime, 'stale-start-time'); + assert.equal(freshLock.state, 'held'); + if (freshLock.state === 'held') assert.notEqual(freshLock.owner.startTime, 'stale-start-time'); assert.deepEqual(daemon.seenPaths, ['GET /health', 'POST /rpc']); } finally { + if (stale.status === 'acquired') await stale.acquisition.release(); await closeLoopbackServer(daemon.server); - fs.rmSync(stateDir, { recursive: true, force: true }); + await finishRegisteredDaemonFixture(stateDir); } }); @@ -502,7 +518,7 @@ test('sendToDaemon does not reuse reachable daemon metadata with mismatched vers stderrCapture.restore(); await closeLoopbackServer(staleDaemon.server); await closeLoopbackServer(freshDaemon.server); - fs.rmSync(stateDir, { recursive: true, force: true }); + await finishRegisteredDaemonFixture(stateDir); vi.unstubAllEnvs(); } } @@ -516,7 +532,6 @@ test('sendToDaemon prints a takeover notice before replacing an unreachable daem const stateDir = makeTempStateDir('agent-device-daemon-unreachable-takeover-'); const paths = resolveDaemonPaths(stateDir); - // Bind fresh BEFORE freeing the port below: a later bind can reclaim it and skip the takeover. const freshDaemon = await startHttpDaemonFixture({ via: 'fresh-daemon' }); const unreachable = await startHttpDaemonFixture({ via: 'unused' }); await closeLoopbackServer(unreachable.server); @@ -546,7 +561,7 @@ test('sendToDaemon prints a takeover notice before replacing an unreachable daem } finally { stderrCapture.restore(); await closeLoopbackServer(freshDaemon.server); - fs.rmSync(stateDir, { recursive: true, force: true }); + await finishRegisteredDaemonFixture(stateDir); } }); @@ -587,7 +602,7 @@ test('sendToDaemon replaces socket-only daemon metadata when HTTP transport is r } finally { stderrCapture.restore(); await closeLoopbackServer(freshDaemon.server); - fs.rmSync(stateDir, { recursive: true, force: true }); + await finishRegisteredDaemonFixture(stateDir); } }); @@ -602,12 +617,18 @@ test('sendRequest timeout cleanup uses resolved daemon paths instead of request const daemonPaths = resolveDaemonPaths(daemonStateDir); const requestFlagPaths = resolveDaemonPaths(requestFlagStateDir); const daemon = await startHangingHttpDaemonFixture(); - writeDaemonInfo(daemonPaths, { - httpPort: daemon.port, - transport: 'http', - pid: 999_999, - }); - writeDaemonLock(daemonPaths, { pid: 999_999 }); + mockSleep.mockImplementation(actualRetry.sleep); + const child = spawnRegisteredDaemonFixture( + daemonPaths, + { + httpPort: daemon.port, + token: 'local-secret', + version: readVersion(), + codeOrigin: 'checkout', + codeSignature: currentDaemonCodeSignature(), + }, + undefined, + ); writeDaemonInfo(requestFlagPaths, { httpPort: daemon.port, transport: 'http', @@ -625,26 +646,21 @@ test('sendRequest timeout cleanup uses resolved daemon paths instead of request }; try { + const info = await waitForRegisteredDaemonFixture(daemonPaths, child); let thrown: unknown; try { - await sendRequest( - { - token: 'local-secret', - pid: 999_999, - httpPort: daemon.port, - transport: 'http', - }, - request, - 'http', - daemonPaths, - 50, - ); + await sendRequest(info, request, 'http', daemonPaths, 50); } catch (error) { thrown = error; } assert.ok(thrown instanceof AppError); assert.equal(thrown.message, 'Daemon request timed out'); + assert.equal( + (thrown.details?.retirement as DaemonRetirementResult | undefined)?.status, + 'retired', + ); + await child.exited; assert.deepEqual(daemon.seenPaths, ['POST /rpc']); assert.equal(fs.existsSync(daemonPaths.infoPath), false); assert.equal(fs.existsSync(daemonPaths.lockPath), false); @@ -652,7 +668,7 @@ test('sendRequest timeout cleanup uses resolved daemon paths instead of request assert.equal(fs.existsSync(requestFlagPaths.lockPath), true); } finally { await closeLoopbackServer(daemon.server); - fs.rmSync(daemonStateDir, { recursive: true, force: true }); + await finishRegisteredDaemonFixture(daemonStateDir); fs.rmSync(requestFlagStateDir, { recursive: true, force: true }); } }); @@ -690,7 +706,7 @@ test('sendToDaemon falls back from failed socket transport to HTTP using daemon } finally { socketFailures.restore(); await closeLoopbackServer(daemon.server); - fs.rmSync(stateDir, { recursive: true, force: true }); + await finishRegisteredDaemonFixture(stateDir); } }); @@ -734,16 +750,10 @@ test('sendToDaemon does not replay over HTTP after the socket request is written } finally { socket.restore(); await closeLoopbackServer(daemon.server); - fs.rmSync(stateDir, { recursive: true, force: true }); + await finishRegisteredDaemonFixture(stateDir); } }); -// --- ADR 0012 decision 6, R7 (Fix 1, C1): a repair-armed `replay --save-script` -// that comes back as a HELD divergence (the daemon's `resume.repairSessionHeld` -// signal) must keep its owning (owned/ephemeral) daemon alive and addressable. -// The keep-alive keys on that signal — the REPAIR-ARMED condition — NOT on -// `resume.allowed`, which reports only plan-resumability. --- - function heldDivergenceError( resume: Record = { allowed: true, from: 3, planDigest: 'digest-abc' }, ): Record { @@ -805,15 +815,13 @@ test('sendToDaemon keeps an owned ephemeral daemon alive and hints its --state-d assert.match(String(response.error.hint), /--state-dir/); assert.ok(String(response.error.hint).includes(ownedStateDir)); - // The daemon was NOT torn down: metadata and the owned state dir itself - // are still on disk, addressable by a follow-up command's --state-dir. const ownedPaths = resolveDaemonPaths(ownedStateDir); assert.equal(fs.existsSync(ownedPaths.infoPath), true); assert.equal(fs.existsSync(ownedPaths.lockPath), true); assert.equal(fs.existsSync(ownedStateDir), true); } finally { await closeLoopbackServer(daemon.server); - if (ownedStateDir) fs.rmSync(ownedStateDir, { recursive: true, force: true }); + if (ownedStateDir) await finishRegisteredDaemonFixture(ownedStateDir); } }); @@ -823,8 +831,6 @@ test('C1: keep-alive keys on repairSessionHeld, NOT resume.allowed — a HELD di return; } - // resume.allowed:false (plan not resumable), but the daemon still HELD the - // repair session — the agent must be able to reach it to close/inspect. const daemon = await startHttpDaemonErrorFixture( heldDivergenceError({ allowed: false, @@ -854,7 +860,7 @@ test('C1: keep-alive keys on repairSessionHeld, NOT resume.allowed — a HELD di assert.equal(fs.existsSync(ownedStateDir), true); } finally { await closeLoopbackServer(daemon.server); - if (ownedStateDir) fs.rmSync(ownedStateDir, { recursive: true, force: true }); + if (ownedStateDir) await finishRegisteredDaemonFixture(ownedStateDir); } }); @@ -883,92 +889,76 @@ test('sendToDaemon tears down an owned ephemeral daemon on an UNHELD divergence if (response.ok) return; assert.equal(response.error.hint, undefined); assert.ok(ownedStateDir.length > 0); - // No held signal (`resume.allowed:true` alone is not the keep-alive key) — - // ordinary one-shot teardown still applies. assert.equal(fs.existsSync(ownedStateDir), false); } finally { await closeLoopbackServer(daemon.server); - if (ownedStateDir) fs.rmSync(ownedStateDir, { recursive: true, force: true }); + if (ownedStateDir) await finishRegisteredDaemonFixture(ownedStateDir); } }); -// --- ADR 0012 decision 6 (BLOCKER 2, third follow-up): a one-shot -// `replay --save-script` that completes with no divergence returns SUCCESS -// immediately — the actual healed-script commit is deferred to daemon -// teardown. If that deferred commit then fails, the daemon leaves a -// REPAIR_COMMIT_FAILED tombstone in the owned state dir before exiting. The -// client cleanup must discover it (after waiting for the daemon to actually -// exit) BEFORE deleting the owned state dir, and must surface it in the -// response the caller receives — never silently delete the only evidence of -// the failure while reporting the success already computed for the replay -// itself. --- - test('BLOCKER 2 (third follow-up): a shutdown-time repair commit failure is surfaced and the owned state dir survives', async (t) => { if (!(await supportsLoopbackBind())) { t.skip('loopback listeners are not permitted in this environment'); return; } - // The daemon's RPC response for the replay itself is a plain SUCCESS (the - // plan completed with no divergence) — exactly what a real daemon would - // return before its deferred, teardown-time commit has even attempted. - const daemon = await startHttpDaemonFixture({ session: 'default' }); - let ownedStateDir = ''; - installSpawnedHttpDaemonAtOwnedStateDir(daemon.port, (dir) => { - ownedStateDir = dir; - // Simulate the daemon's OWN shutdown handler (`finalizeRepairTeardown`) - // having already run and left a commit-failure tombstone before this - // fake process "exits" — the real ordering `stopDaemonProcessForTakeover` - // depends on (it waits for the process to exit, and the real daemon only - // exits after teardown finishes writing this file). - const ownedPaths = resolveDaemonPaths(dir); - const sessionDir = path.join(ownedPaths.sessionsDir, 'default'); - fs.mkdirSync(sessionDir, { recursive: true }); - fs.writeFileSync( - path.join(sessionDir, 'repair-tombstone.json'), - `${JSON.stringify({ - owner: 'default', - reapedAt: Date.now(), - expiresAt: Date.now() + 60_000, - sourcePath: '/tmp/flow.ad', - commitFailure: { - code: 'COMMAND_FAILED', - message: 'a prior healed script already exists at /tmp/flow.healed.ad', - }, - })}\n`, - ); - }); - - try { - const response = await sendToDaemon({ - session: 'default', - command: 'replay', - positionals: ['flow.ad'], - flags: { saveScript: true, daemonTransport: 'http' }, - meta: { requestId: 'req-repair-commit-fail-teardown' }, + for (const failRelease of [false, true]) { + const daemon = await startHttpDaemonFixture({ session: 'default' }); + let ownedStateDir = ''; + installSpawnedHttpDaemonAtOwnedStateDir(daemon.port, (dir) => { + ownedStateDir = dir; + fs.writeFileSync( + path.join(dir, 'repair-on-shutdown.json'), + `${JSON.stringify({ + owner: 'default', + reapedAt: Date.now(), + expiresAt: Date.now() + 60_000, + sourcePath: '/tmp/flow.ad', + commitFailure: { + code: 'COMMAND_FAILED', + message: 'a prior healed script already exists at /tmp/flow.healed.ad', + }, + })}\n`, + ); }); - // The client-visible response must surface the deferred commit failure — - // never the raw success the daemon returned for the replay itself, and - // never silently swallowed by cleanup. - assert.equal(response.ok, false); - if (response.ok) return; - assert.equal(response.error.code, 'REPAIR_COMMIT_FAILED'); - assert.match(response.error.message, /a prior healed script already exists/); - assert.ok(response.error.message.includes('replay /tmp/flow.ad --save-script')); + const originalRmdir = fs.rmdirSync; + const releaseSpy = vi.spyOn(fs, 'rmdirSync').mockImplementation((target, options) => { + if (failRelease && target === resolveDaemonPaths(ownedStateDir).lockPath) + throw Object.assign(new Error('release failed'), { code: 'EBUSY' }); + return originalRmdir(target, options); + }); + try { + const response = await sendToDaemon({ + session: 'default', + command: 'replay', + positionals: ['flow.ad'], + flags: { saveScript: true, daemonTransport: 'http' }, + meta: { requestId: 'req-repair-commit-fail-teardown' }, + }); - // The owned state dir — and the tombstone evidence inside it — must - // survive: never rmSync'd while an unrecovered commit failure is on record. - assert.ok(ownedStateDir.length > 0); - assert.equal(fs.existsSync(ownedStateDir), true); - const ownedPaths = resolveDaemonPaths(ownedStateDir); - assert.equal( - fs.existsSync(path.join(ownedPaths.sessionsDir, 'default', 'repair-tombstone.json')), - true, - ); - } finally { - await closeLoopbackServer(daemon.server); - if (ownedStateDir) fs.rmSync(ownedStateDir, { recursive: true, force: true }); + assert.ok(!response.ok); + assert.equal(response.error.code, 'REPAIR_COMMIT_FAILED'); + const secondary = response.error.details?.cleanupFailure as + | { details?: { ownerReleaseUnverified?: boolean }; hint?: string } + | undefined; + assert.equal(Boolean(secondary?.details?.ownerReleaseUnverified), failRelease); + assert.equal(Boolean(secondary?.hint?.startsWith('Restore process inspection')), failRelease); + assert.match(response.error.message, /a prior healed script already exists/); + assert.ok(response.error.message.includes('replay /tmp/flow.ad --save-script')); + + assert.ok(ownedStateDir.length > 0); + assert.equal(fs.existsSync(ownedStateDir), true); + const ownedPaths = resolveDaemonPaths(ownedStateDir); + assert.equal( + fs.existsSync(path.join(ownedPaths.sessionsDir, 'default', 'repair-tombstone.json')), + true, + ); + } finally { + releaseSpy.mockRestore(); + await closeLoopbackServer(daemon.server); + if (ownedStateDir) await finishRegisteredDaemonFixture(ownedStateDir); + } } }); @@ -1003,7 +993,7 @@ test('continuation: sendToDaemon keeps the daemon alive on a held divergence eve assert.equal(fs.existsSync(ownedStateDir), true); } finally { await closeLoopbackServer(daemon.server); - if (ownedStateDir) fs.rmSync(ownedStateDir, { recursive: true, force: true }); + if (ownedStateDir) await finishRegisteredDaemonFixture(ownedStateDir); } }); @@ -1131,7 +1121,7 @@ test('sendToDaemon keeps an owned ephemeral daemon alive and hints its --state-d assert.equal(fs.existsSync(ownedStateDir), true); } finally { await closeLoopbackServer(daemon.server); - if (ownedStateDir) fs.rmSync(ownedStateDir, { recursive: true, force: true }); + if (ownedStateDir) await finishRegisteredDaemonFixture(ownedStateDir); } }); @@ -1170,7 +1160,7 @@ test('closes the loop: a follow-up sendToDaemon using the hinted --state-dir/--s assert.equal(daemon.rpcRequests[1]?.params?.command, 'press'); } finally { await closeLoopbackServer(daemon.server); - if (ownedStateDir) fs.rmSync(ownedStateDir, { recursive: true, force: true }); + if (ownedStateDir) await finishRegisteredDaemonFixture(ownedStateDir); } }); @@ -1202,7 +1192,7 @@ test('sendToDaemon tears down an owned ephemeral daemon when replay reports the assert.equal(fs.existsSync(ownedStateDir), false); } finally { await closeLoopbackServer(daemon.server); - if (ownedStateDir) fs.rmSync(ownedStateDir, { recursive: true, force: true }); + if (ownedStateDir) await finishRegisteredDaemonFixture(ownedStateDir); } }); @@ -1248,7 +1238,7 @@ test('ADR 0012 R7 x ADR 0016: a completed --save-script repair also keeps its ow assert.equal(fs.existsSync(ownedStateDir), true); } finally { await closeLoopbackServer(daemon.server); - if (ownedStateDir) fs.rmSync(ownedStateDir, { recursive: true, force: true }); + if (ownedStateDir) await finishRegisteredDaemonFixture(ownedStateDir); } }); @@ -1294,7 +1284,7 @@ test('issue #1384: sendToDaemon does not stop a client-started daemon at an expl assert.equal(fs.existsSync(paths.lockPath), true); } finally { await closeLoopbackServer(daemon.server); - fs.rmSync(stateDir, { recursive: true, force: true }); + await finishRegisteredDaemonFixture(stateDir); } }); @@ -1328,6 +1318,6 @@ test('sendToDaemon still tears down a `test` command owned ephemeral daemon even assert.equal(fs.existsSync(ownedStateDir), false); } finally { await closeLoopbackServer(daemon.server); - if (ownedStateDir) fs.rmSync(ownedStateDir, { recursive: true, force: true }); + if (ownedStateDir) await finishRegisteredDaemonFixture(ownedStateDir); } }); diff --git a/src/daemon-client/__tests__/daemon-client-metadata.test.ts b/src/daemon-client/__tests__/daemon-client-metadata.test.ts index 5b46a91e8d..bae4084103 100644 --- a/src/daemon-client/__tests__/daemon-client-metadata.test.ts +++ b/src/daemon-client/__tests__/daemon-client-metadata.test.ts @@ -1,27 +1,13 @@ import assert from 'node:assert/strict'; -import { AppError, normalizeError } from '@agent-device/kernel/errors'; import fs from 'node:fs'; import path from 'node:path'; -import { afterEach, test, vi } from 'vitest'; +import { test } from 'vitest'; import type { DaemonCodeOrigin } from '@agent-device/host-kit/code-signature'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; import { tryAcquireDaemonRegistration } from '../../daemon-registration-owner.ts'; -import { - readDaemonInfo, - cleanupFailedDaemonStartupMetadata, - stopDaemonProcessForTakeover, - type DaemonInfo, -} from '../daemon-client-metadata.ts'; -import { isAgentDeviceDaemonProcess, stopDaemonProcess } from '../../daemon-process.ts'; +import { readDaemonInfo, type DaemonInfo } from '../daemon-client-metadata.ts'; import { resolveDaemonPaths } from '../../daemon-resolution.ts'; -vi.mock('../../daemon-process.ts', async (importOriginal) => ({ - ...(await importOriginal()), - isAgentDeviceDaemonProcess: vi.fn(), - stopDaemonProcess: vi.fn(), -})); -afterEach(() => vi.resetAllMocks()); - // The reuse decision is only as good as the identity that survives the round trip // through `daemon.json`: a client cannot compare what the file lost (#2458). @@ -66,39 +52,3 @@ test('a registration this version did not write reads back unreported', () => { assert.equal(readDaemonInfo(infoPath)?.codeOrigin, undefined); } }); - -for (const artifact of ['daemon.json', 'daemon.lock']) { - test(`unconfirmed startup stop retains ${artifact} without claiming cleanup`, async () => { - const [stateDir] = scratchStateDir(); - const paths = resolveDaemonPaths(stateDir); - const file = path.join(stateDir, artifact); - const contents = JSON.stringify({ - pid: 7, - processStartTime: 'start', - port: 1234, - token: 'secret', - }); - fs.writeFileSync(file, contents); - vi.mocked(isAgentDeviceDaemonProcess).mockReturnValue(true); - vi.mocked(stopDaemonProcess).mockResolvedValue({ status: 'retained', reason: 'exit-timeout' }); - const result = await cleanupFailedDaemonStartupMetadata(paths, 'start_error'); - assert.equal(fs.readFileSync(file, 'utf8'), contents); - assert.equal(result.removedInfo, false); - assert.equal(result.removedLock, false); - assert.equal(result.stoppedInfoProcess, false); - assert.equal(result.stoppedLockProcess, false); - assert.match(result.error ?? '', /exit could not be confirmed/); - }); -} - -test('a retained takeover keeps its reason at the normalized error boundary', async () => { - vi.mocked(stopDaemonProcess).mockResolvedValue({ status: 'retained', reason: 'exit-timeout' }); - await assert.rejects( - stopDaemonProcessForTakeover({ pid: 7, token: 'secret', processStartTime: 'start' }), - (error: unknown) => { - assert.ok(error instanceof AppError); - assert.equal(normalizeError(error).details?.reason, 'daemon_exit_unconfirmed'); - return true; - }, - ); -}); diff --git a/src/daemon-client/__tests__/daemon-client-startup-race.test.ts b/src/daemon-client/__tests__/daemon-client-startup-race.test.ts index d52ba0e6c2..60eb5a9366 100644 --- a/src/daemon-client/__tests__/daemon-client-startup-race.test.ts +++ b/src/daemon-client/__tests__/daemon-client-startup-race.test.ts @@ -1,7 +1,24 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; -import { afterEach, beforeAll, test, vi } from 'vitest'; +import path from 'node:path'; +import { afterEach, test, vi } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; +import { tryAcquireProcessLock, inspectProcessLock } from '@agent-device/host-kit/file'; +import { readCurrentOwnerIdentity, isProcessAlive } from '@agent-device/host-kit/process'; +import { readVersion } from '@agent-device/host-kit/version'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { + spawnRegisteredDaemonFixture, + finishRegisteredDaemonFixtures, +} from '../../__tests__/test-utils/registered-daemon-fixture.ts'; +import { + startHttpDaemonFixture, + currentDaemonCodeSignature, +} from '../../__tests__/test-utils/daemon-http-fixture.ts'; +import { closeLoopbackServer, supportsLoopbackBind } from '../../__tests__/test-utils/loopback.ts'; +import { resolveDaemonPaths, type DaemonPaths } from '../../daemon-resolution.ts'; +import { sendToDaemon } from '../daemon-client.ts'; +import { DAEMON_STARTUP_EXIT_CODES } from '../../daemon-registration-owner.ts'; vi.mock('@agent-device/host-kit/command', async (importOriginal) => ({ ...(await importOriginal()), @@ -9,205 +26,383 @@ vi.mock('@agent-device/host-kit/command', async (importOriginal) => ({ })); vi.mock('@agent-device/host-kit/retry', async (importOriginal) => ({ ...(await importOriginal()), - sleep: vi.fn(async () => {}), + sleep: vi.fn(), })); -const winner = vi.hoisted(() => ({ pid: 43_300, alive: true })); -vi.mock('../../daemon-process.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - isAgentDeviceDaemonProcess: vi.fn((pid: number, startTime: string | undefined) => - pid === winner.pid ? winner.alive : actual.isAgentDeviceDaemonProcess(pid, startTime), - ), - stopDaemonProcess: vi.fn( - async ( - identity: Parameters[0], - options: Parameters[1], - ) => { - if (identity.pid !== winner.pid) return await actual.stopDaemonProcess(identity, options); - winner.alive = false; - return { - status: 'exited' as const, - identity: { pid: identity.pid, startTime: identity.startTime! }, - mode: 'graceful' as const, - }; - }, - ), - }; -}); - -import { resolveDaemonPaths, type DaemonPaths } from '../../daemon-resolution.ts'; -import { sendToDaemon } from '../daemon-client.ts'; import { runCmdDetachedMonitored, type ExecDetachedExit } from '@agent-device/host-kit/command'; import { sleep } from '@agent-device/host-kit/retry'; -import { readVersion } from '@agent-device/host-kit/version'; -import { resolveLocalDaemonCodeIdentity } from '../daemon-launch-spec.ts'; -import { - startHttpDaemonFixture, - type HttpDaemonFixture, -} from '../../__tests__/test-utils/daemon-http-fixture.ts'; -import { closeLoopbackServer, supportsLoopbackBind } from '../../__tests__/test-utils/loopback.ts'; - -// Two clients that find no daemon both launch one; the daemon that loses the startup lock exits -// cleanly. These pin that the losing client adopts the winner instead of tearing it down. - -const WINNER_PID = winner.pid; -const LOSER_PID = 43_301; - -const mockRunCmdDetached = vi.mocked(runCmdDetachedMonitored); -const mockSleep = vi.mocked(sleep); - -afterEach(() => { - winner.alive = true; - mockRunCmdDetached.mockReset(); - mockSleep.mockReset(); - mockSleep.mockImplementation(async () => {}); - vi.unstubAllEnvs(); +const actualRetry = await vi.importActual( + '@agent-device/host-kit/retry', +); +const spawn = vi.mocked(runCmdDetachedMonitored); +const pause = vi.mocked(sleep); +afterEach(async () => { + vi.restoreAllMocks(); + await finishRegisteredDaemonFixtures(); + spawn.mockReset(); + pause.mockReset(); }); -/** The code signature this client stamps on, and expects of, a daemon it may reuse. */ -let codeSignature: string | undefined; - -beforeAll(async () => { - const identity = await resolveLocalDaemonCodeIdentity(); - codeSignature = identity.origin === 'installed' ? undefined : identity.codeSignature; -}); +function request(paths: DaemonPaths, command = 'devices') { + return { + session: 'default', + command, + positionals: [], + flags: { stateDir: paths.baseDir, daemonTransport: 'http' as const }, + }; +} +function fields(httpPort: number, version = readVersion()) { + return { + httpPort, + token: 'secret', + version, + codeOrigin: 'checkout' as const, + codeSignature: currentDaemonCodeSignature(), + }; +} +async function awaitFile(file: string) { + const deadline = Date.now() + 2_000; + while (!fs.existsSync(file)) { + assert.ok(Date.now() < deadline, `fixture did not publish ${file}`); + await actualRetry.sleep(10); + } +} -/** Records the winning daemon the way it would: the startup lock, then its reachable metadata. */ -function writeWinner( - paths: DaemonPaths, - fixture: HttpDaemonFixture, - parts: 'lock' | 'all', - version = readVersion(), -): void { - fs.mkdirSync(paths.baseDir, { recursive: true }); - fs.writeFileSync( - paths.lockPath, - JSON.stringify({ pid: WINNER_PID, processStartTime: 'winner', startedAt: Date.now() }), - ); - if (parts === 'lock') return; - fs.writeFileSync( - paths.infoPath, - JSON.stringify({ - token: 'winner-secret', - pid: WINNER_PID, - version, - codeSignature, - processStartTime: 'winner', - httpPort: fixture.port, - transport: 'http', - }), - ); +for (const command of ['devices', 'test']) { + test(`a joined busy contender adopts a real winner for ${command}`, async (t) => { + if (!(await supportsLoopbackBind())) return t.skip('loopback unavailable'); + const paths = resolveDaemonPaths(mkdtempForTestSync('daemon-start-winner-')); + const http = await startHttpDaemonFixture({ devices: [] }); + const deferred = path.join(paths.baseDir, 'defer-publication'); + fs.writeFileSync(deferred, 'wait'); + const winner = spawnRegisteredDaemonFixture(paths, fields(http.port), { stdio: 'ignore' }); + await awaitFile(path.join(paths.baseDir, 'registration-held')); + fs.writeFileSync( + paths.infoPath, + JSON.stringify({ ...fields(http.port, '0.0.1'), pid: 999_999_999, processStartTime: 'old' }), + ); + let joined = false; + let genuineExit: ExecDetachedExit | undefined; + let contender: ReturnType | undefined; + let releaseJoin: (exit: ExecDetachedExit) => void = () => {}; + let pauses = 0; + spawn.mockImplementation((_command, _args, options) => { + contender = spawnRegisteredDaemonFixture(paths, fields(http.port), options); + void contender.exited.then((exit) => { + assert.equal(exit.exitCode, DAEMON_STARTUP_EXIT_CODES.busy); + genuineExit = exit; + }); + return { + ...contender, + exited: new Promise((resolve) => { + releaseJoin = resolve; + }), + }; + }); + pause.mockImplementation(async (ms) => { + pauses += 1; + fs.rmSync(deferred, { force: true }); + await awaitFile(paths.infoPath); + await actualRetry.sleep(ms); + if (pauses >= 2 && genuineExit) { + joined = true; + releaseJoin(genuineExit); + } + }); + try { + const response = await sendToDaemon(request(paths, command)); + assert.equal(response.ok, true); + assert.equal(joined, true); + assert.equal(spawn.mock.calls.length, 1); + assert.equal(http.rpcRequests.length, 1); + assert.equal(isProcessAlive(winner.pid), true); + const claim = inspectProcessLock(paths.lockPath); + assert.equal(claim.state, 'held'); + if (claim.state === 'held') assert.equal(claim.owner.pid, winner.pid); + } finally { + if (contender) releaseJoin(await contender.exited); + await closeLoopbackServer(http.server); + } + }); } -test('a client whose daemon lost the startup lock uses the daemon that won it', async (t) => { - if (!(await supportsLoopbackBind())) { - t.skip('loopback listeners are not permitted in this environment'); - return; - } - const stateDir = mkdtempForTestSync('agent-device-daemon-start-race-'); - const paths = resolveDaemonPaths(stateDir); - vi.stubEnv('AGENT_DEVICE_STATE_DIR', stateDir); - const fixture = await startHttpDaemonFixture({ devices: [] }); - let launches = 0; - mockRunCmdDetached.mockImplementation(() => { - launches += 1; - writeWinner(paths, fixture, 'lock'); - const exit: ExecDetachedExit = { pid: LOSER_PID, exitCode: 0 }; - return { pid: LOSER_PID, exited: Promise.resolve(exit) }; +test('a client-held claim is waited out before a fresh daemon attempt', async (t) => { + if (!(await supportsLoopbackBind())) return t.skip('loopback unavailable'); + const paths = resolveDaemonPaths(mkdtempForTestSync('daemon-start-client-holder-')); + const http = await startHttpDaemonFixture({ devices: [] }); + const claim = tryAcquireProcessLock({ + lockDirPath: paths.lockPath, + owner: { ...readCurrentOwnerIdentity(), acquiredAtMs: Date.now() }, }); - mockSleep.mockImplementation(async () => { - if (!fs.existsSync(paths.infoPath)) writeWinner(paths, fixture, 'all'); + assert.equal(claim.status, 'acquired'); + if (claim.status !== 'acquired') throw new Error('fixture claim refused'); + let loserJoined = false; + let released = false; + spawn.mockImplementation((_command, _args, options) => { + const child = spawnRegisteredDaemonFixture(paths, fields(http.port), options); + if (spawn.mock.calls.length === 1) + void child.exited.then((exit) => { + assert.equal(exit.exitCode, DAEMON_STARTUP_EXIT_CODES.busy); + loserJoined = true; + }); + else assert.equal(loserJoined, true); + return child; + }); + pause.mockImplementation(async (ms) => { + if (loserJoined && !released) { + await claim.acquisition.release(); + released = true; + } + await actualRetry.sleep(ms); }); - try { - const response = await sendToDaemon({ - session: 'default', - command: 'devices', - positionals: [], - flags: { stateDir }, - meta: { requestId: 'req-start-race' }, - }); - - assert.equal(response.ok, true); - assert.equal(launches, 1); - assert.equal(fixture.rpcRequests.length, 1); - assert.equal(fs.existsSync(paths.infoPath), true); - assert.equal(fs.existsSync(paths.lockPath), true); + assert.equal((await sendToDaemon(request(paths))).ok, true); + assert.equal(spawn.mock.calls.length, 2); + assert.equal(http.rpcRequests.length, 1); } finally { - await closeLoopbackServer(fixture.server); - fs.rmSync(stateDir, { recursive: true, force: true }); + if (!released) await claim.acquisition.release(); + await closeLoopbackServer(http.server); } }); -test('a one-shot test run leaves a daemon another client started running', async (t) => { - if (!(await supportsLoopbackBind())) { - t.skip('loopback listeners are not permitted in this environment'); - return; - } - const stateDir = mkdtempForTestSync('agent-device-daemon-start-race-owner-'); - const paths = resolveDaemonPaths(stateDir); - vi.stubEnv('AGENT_DEVICE_STATE_DIR', stateDir); - const fixture = await startHttpDaemonFixture({ passed: 1, failed: 0 }); - mockRunCmdDetached.mockImplementation(() => { - writeWinner(paths, fixture, 'all'); - return { pid: LOSER_PID, exited: new Promise(() => {}) }; +for (const exit of [ + { exitCode: 0 }, + { exitCode: 1 }, + { exitCode: DAEMON_STARTUP_EXIT_CODES.unproven }, + { exitCode: DAEMON_STARTUP_EXIT_CODES.busy, error: 'spawn refused' }, + { exitCode: DAEMON_STARTUP_EXIT_CODES.busy, signal: 'SIGTERM' as const }, +]) { + test(`generic exit ${exit.error ?? exit.signal ?? exit.exitCode} cannot adopt or stop a foreign winner`, async (t) => { + if (!(await supportsLoopbackBind())) return t.skip('loopback unavailable'); + const paths = resolveDaemonPaths(mkdtempForTestSync('daemon-start-generic-exit-')); + const http = await startHttpDaemonFixture({ devices: [] }); + const deferred = path.join(paths.baseDir, 'defer-publication'); + fs.writeFileSync(deferred, 'wait'); + const winner = spawnRegisteredDaemonFixture(paths, fields(http.port), { stdio: 'ignore' }); + await awaitFile(path.join(paths.baseDir, 'registration-held')); + spawn.mockImplementation(() => ({ + pid: 999_999, + exited: Promise.resolve({ pid: 999_999, ...exit }), + })); + pause.mockImplementation(async (ms) => { + fs.rmSync(deferred, { force: true }); + await actualRetry.sleep(ms); + }); + try { + await assert.rejects( + sendToDaemon(request(paths)), + (error: unknown) => + error instanceof AppError && error.details?.kind === 'daemon_startup_failed', + ); + assert.equal(spawn.mock.calls.length, 1); + assert.equal(http.rpcRequests.length, 0); + assert.equal(isProcessAlive(winner.pid), true); + assert.equal(inspectProcessLock(paths.lockPath).state, 'held'); + } finally { + await closeLoopbackServer(http.server); + } }); +} +test('a joined busy contender waits for a published winner to become ready without signaling it', async (t) => { + if (!(await supportsLoopbackBind())) return t.skip('loopback unavailable'); + const paths = resolveDaemonPaths(mkdtempForTestSync('daemon-start-delayed-ready-')); + let probes = 0; + const http = await startHttpDaemonFixture({ devices: [] }, { ready: () => ++probes >= 12 }); + const deferred = path.join(paths.baseDir, 'defer-publication'); + fs.writeFileSync(deferred, 'wait'); + const winner = spawnRegisteredDaemonFixture(paths, fields(http.port), { stdio: 'ignore' }); + await awaitFile(path.join(paths.baseDir, 'registration-held')); + let contenderExit: ExecDetachedExit | undefined; + spawn.mockImplementation((_command, _args, options) => { + const child = spawnRegisteredDaemonFixture(paths, fields(http.port), options); + void child.exited.then((exit) => { + contenderExit = exit; + }); + return child; + }); + pause.mockImplementation(async () => { + if (contenderExit) fs.rmSync(deferred, { force: true }); + await actualRetry.sleep(10); + }); + const signal = vi.spyOn(process, 'kill'); try { - const response = await sendToDaemon({ - session: 'default', - command: 'test', - positionals: [], - flags: { stateDir }, - meta: { requestId: 'req-start-race-test' }, + assert.equal((await sendToDaemon(request(paths))).ok, true); + assert.equal(contenderExit?.exitCode, DAEMON_STARTUP_EXIT_CODES.busy); + assert.ok(probes >= 12); + assert.equal(spawn.mock.calls.length, 1); + assert.equal(http.rpcRequests.length, 1); + assert.equal(isProcessAlive(winner.pid), true); + assert.equal( + signal.mock.calls.some(([pid, kind]) => pid === winner.pid && kind !== 0), + false, + ); + } finally { + signal.mockRestore(); + await closeLoopbackServer(http.server); + } +}); + +for (const held of [true, false]) { + test(`startup uses one deadline when the claim is ${held ? 'held' : 'released for relaunch'}`, async () => { + const paths = resolveDaemonPaths(mkdtempForTestSync('daemon-start-budget-')); + const claim = tryAcquireProcessLock({ + lockDirPath: paths.lockPath, + owner: { ...readCurrentOwnerIdentity(), acquiredAtMs: Date.now() }, + }); + assert.equal(claim.status, 'acquired'); + if (claim.status !== 'acquired') throw new Error('fixture claim refused'); + let now = Date.now(); + const started = now; + let released = false; + let advanced = false; + let finishPending: () => void = () => {}; + const nativeTimeout = globalThis.setTimeout; + vi.spyOn(globalThis, 'setTimeout').mockImplementation((handler, ms, ...args) => + nativeTimeout(handler, ms === 1_000 ? 0 : ms, ...args), + ); + vi.spyOn(Date, 'now').mockImplementation(() => now); + spawn.mockImplementation(() => ({ + pid: 999_999, + exited: + spawn.mock.calls.length === 1 + ? Promise.resolve({ pid: 999_999, exitCode: DAEMON_STARTUP_EXIT_CODES.busy }) + : new Promise((resolve) => { + finishPending = () => resolve({ pid: 999_999, exitCode: 1 }); + }), + })); + pause.mockImplementation(async (ms) => { + if (!advanced) { + if (!held) { + await claim.acquisition.release(); + released = true; + } + advanced = true; + now += 14_750; + } else now += ms; }); + try { + await assert.rejects(sendToDaemon(request(paths)), (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.startupAttempts, held ? 1 : 2); + assert.equal(error.details?.startupTimeoutMs, 15_000); + return true; + }); + assert.equal(now - started, 15_000); + assert.equal(inspectProcessLock(paths.lockPath).state, held ? 'held' : 'absent'); + } finally { + finishPending(); + vi.restoreAllMocks(); + if (!released) await claim.acquisition.release(); + } + }); +} - assert.equal(response.ok, true); - assert.equal(fs.existsSync(paths.infoPath), true); +test.for([ + { budget: 'ample', offset: 0, launches: 2, alive: false, rpcs: 1 }, + { budget: 'near deadline', offset: 11_000, launches: 1, alive: true, rpcs: 0 }, +])('an older winner is replaced only with enough startup time ($budget)', async (expected, t) => { + if (!(await supportsLoopbackBind())) return t.skip('loopback unavailable'); + const paths = resolveDaemonPaths(mkdtempForTestSync('daemon-start-older-winner-')); + const http = await startHttpDaemonFixture({ devices: [] }); + const deferred = path.join(paths.baseDir, 'defer-publication'); + fs.writeFileSync(deferred, 'wait'); + const winner = spawnRegisteredDaemonFixture(paths, fields(http.port, '0.0.1'), { + stdio: 'ignore', + }); + await awaitFile(path.join(paths.baseDir, 'registration-held')); + const wallTime = Date.now; + let offset = 0; + const clock = vi.spyOn(Date, 'now').mockImplementation(() => wallTime() + offset); + let joined = false; + spawn.mockImplementation((_command, _args, options) => { + if (spawn.mock.calls.length > 1) assert.equal(joined, true); + const child = spawnRegisteredDaemonFixture(paths, fields(http.port), options); + if (spawn.mock.calls.length === 1) + void child.exited.then((exit) => { + assert.equal(exit.exitCode, DAEMON_STARTUP_EXIT_CODES.busy); + joined = true; + }); + return child; + }); + pause.mockImplementation(async (ms) => { + if (joined) { + fs.rmSync(deferred, { force: true }); + if (expected.offset) offset = offset ? offset + ms : expected.offset; + } + await actualRetry.sleep(10); + }); + const notice = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + const pending = sendToDaemon(request(paths)); + if (expected.alive) + await assert.rejects(pending, (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.kind, 'daemon_startup_failed'); + assert.equal(error.details?.startupAttempts, 1); + assert.equal(error.details?.startupTimeoutMs, 15_000); + return true; + }); + else { + assert.equal((await pending).ok, true); + await winner.exited; + } + assert.equal(joined, true); + assert.equal(isProcessAlive(winner.pid), expected.alive); + assert.equal(spawn.mock.calls.length, expected.launches); + assert.equal(http.rpcRequests.length, expected.rpcs); + assert.equal( + notice.mock.calls.flat().join('').includes(`Replacing daemon (pid ${winner.pid}, v0.0.1)`), + !expected.alive, + ); } finally { - await closeLoopbackServer(fixture.server); - fs.rmSync(stateDir, { recursive: true, force: true }); + clock.mockRestore(); + notice.mockRestore(); + await closeLoopbackServer(http.server); } }); -test('a start race won by an older daemon replaces it instead of adopting it', async (t) => { - if (!(await supportsLoopbackBind())) { - t.skip('loopback listeners are not permitted in this environment'); - return; - } - const stateDir = mkdtempForTestSync('agent-device-daemon-start-race-older-'); - const paths = resolveDaemonPaths(stateDir); - vi.stubEnv('AGENT_DEVICE_STATE_DIR', stateDir); - const fixture = await startHttpDaemonFixture({ devices: [] }); - let launches = 0; - mockRunCmdDetached.mockImplementation(() => { - launches += 1; - if (launches === 1) writeWinner(paths, fixture, 'all', '0.0.1'); - const exit: ExecDetachedExit = { pid: LOSER_PID, exitCode: 0 }; - return { pid: LOSER_PID, exited: Promise.resolve(exit) }; +test('a failed own transport probe retires and joins the private startup before rejecting', async (t) => { + if (!(await supportsLoopbackBind())) return t.skip('loopback unavailable'); + const http = await startHttpDaemonFixture({ devices: [] }); + let paths: DaemonPaths | undefined; + let child: ReturnType | undefined; + let failure: AppError | undefined; + spawn.mockImplementation((_command, _args, options) => { + paths = resolveDaemonPaths(String(options?.env?.AGENT_DEVICE_STATE_DIR)); + child = spawnRegisteredDaemonFixture(paths, fields(http.port), options); + return child; }); - const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); - + pause.mockImplementation(actualRetry.sleep); try { await assert.rejects( sendToDaemon({ session: 'default', - command: 'devices', + command: 'test', positionals: [], - flags: { stateDir }, - meta: { requestId: 'req-start-race-older' }, + flags: { daemonTransport: 'socket', daemonServerMode: 'http' }, }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.message, 'Daemon socket endpoint is unavailable'); + assert.equal(error.details?.reason, 'daemon_endpoint_unavailable'); + failure = error; + return true; + }, ); - assert.equal(fixture.rpcRequests.length, 0); - assert.equal(launches, 2); - assert.match( - String(stderr.mock.calls.flat().join('')), - /Replacing daemon \(pid 43300, v0\.0\.1\)/, - ); + assert.ok(paths && child && failure); + assert.equal(isProcessAlive(child.pid), false); + assert.equal(fs.existsSync(paths.baseDir), false); + await child.exited; + assert.equal(failure.details?.startupJoined, true); + assert.equal(failure.details?.stateDir, paths.baseDir); + const results = failure.details?.cleanupResults as Array<{ + status: string; + removedStateDir?: boolean; + }>; + assert.equal(results[0]?.status, 'retired'); + assert.equal(results[0]?.removedStateDir, true); + assert.equal(http.rpcRequests.length, 0); } finally { - stderr.mockRestore(); - await closeLoopbackServer(fixture.server); - fs.rmSync(stateDir, { recursive: true, force: true }); + await closeLoopbackServer(http.server); } }); diff --git a/src/daemon-client/__tests__/daemon-client-timeout-route.test.ts b/src/daemon-client/__tests__/daemon-client-timeout-route.test.ts index f02582c40e..88415d1a23 100644 --- a/src/daemon-client/__tests__/daemon-client-timeout-route.test.ts +++ b/src/daemon-client/__tests__/daemon-client-timeout-route.test.ts @@ -26,19 +26,11 @@ import net from 'node:net'; import http from 'node:http'; import path from 'node:path'; +import fs from 'node:fs'; import assert from 'node:assert/strict'; import { beforeEach, afterEach, test, vi } from 'vitest'; -const { mockRunCmdSync, mockIsDaemon, mockStop } = vi.hoisted(() => ({ - mockRunCmdSync: vi.fn(), - mockIsDaemon: vi.fn(), - mockStop: vi.fn(), -})); -vi.mock('../../daemon-process.ts', async (importOriginal) => ({ - ...(await importOriginal()), - isAgentDeviceDaemonProcess: mockIsDaemon, - stopDaemonProcess: mockStop, -})); +const { mockRunCmdSync } = vi.hoisted(() => ({ mockRunCmdSync: vi.fn() })); vi.mock('@agent-device/host-kit/command', async () => { const actual = await vi.importActual( @@ -47,18 +39,31 @@ vi.mock('@agent-device/host-kit/command', async () => { return { ...actual, runCmdSync: mockRunCmdSync }; }); -import { AppError } from '@agent-device/kernel/errors'; +import { AppError, normalizeError } from '@agent-device/kernel/errors'; +import { sleep } from '@agent-device/host-kit/retry'; +import { withDiagnosticsScope } from '@agent-device/host-kit/diagnostics'; import { sendRequest } from '../daemon-client-transport.ts'; import type { DaemonRequest } from '../../daemon/daemon-request.ts'; import type { DaemonInfo } from '../daemon-client-metadata.ts'; -import type { DaemonPaths } from '../../daemon-resolution.ts'; +import { resolveDaemonPaths, type DaemonPaths } from '../../daemon-resolution.ts'; +import type { DaemonRetirementResult } from '../../daemon-registration-owner.ts'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; +import { + spawnRegisteredDaemonFixture, + waitForRegisteredDaemonFixture, + finishRegisteredDaemonFixture, + finishRegisteredDaemonFixtures, +} from '../../__tests__/test-utils/registered-daemon-fixture.ts'; +import { + closeLoopbackServer, + skipWhenLoopbackUnavailable, +} from '../../__tests__/test-utils/loopback.ts'; const TIMEOUT_MS = 120; // `snapshot`'s timeout policy preserves the daemon (onTimeout !== -// 'reset-daemon'), so `handleRequestTimeout` never reaches -// `resetDaemonAfterTimeout` (`process.kill`) here — keeping this suite +// 'reset-daemon'), so `handleRequestTimeout` never signals the daemon +// in the hint controls — keeping them // side-effect-free outside the mocked pkill sweep. const SNAPSHOT_COMMAND = 'snapshot'; @@ -94,6 +99,7 @@ function startHangingSocketServer(): Promise<{ server: net.Server; port: number // Accept the connection but never write a response — forces the // client's own request-timeout envelope to fire. socket.on('error', () => {}); + socket.resume(); }); server.on('error', reject); server.listen(0, '127.0.0.1', () => { @@ -129,10 +135,11 @@ function startHangingHttpServer(): Promise<{ server: http.Server; port: number } beforeEach(() => { mockRunCmdSync.mockReset(); - mockIsDaemon.mockReset(); - mockStop.mockReset(); }); -afterEach(() => vi.restoreAllMocks()); +afterEach(async () => { + vi.restoreAllMocks(); + await finishRegisteredDaemonFixtures(); +}); test('socket timeout: pkill cleanup still runs for a declared non-Apple platform that actually terminates a runner (rebound-session case), and the hint claims Apple on that evidence', async () => { // Simulates --session-lock strip silently rebinding this request onto an @@ -279,32 +286,205 @@ test('remote HTTP timeout never runs the Apple pkill cleanup and uses the remote assert.equal(mockRunCmdSync.mock.calls.length, 0); }); -test('a refused timeout fallback preserves the timeout without an unhandled rejection', async () => { +async function waitForForceStop(requested: Promise): Promise { + let timer: ReturnType | undefined; + try { + await Promise.race([ + requested, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error('force stop was not requested')), 1_500); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +function timeoutDiagnostic(paths: DaemonPaths): Record { + const events = fs + .readFileSync(path.join(paths.baseDir, 'timeout-diagnostics.ndjson'), 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + const event = events.find((entry) => entry.phase === 'daemon_request_timeout'); + assert.ok(event); + return event.data; +} + +function assertForcedRetirement( + retirement: DaemonRetirementResult | undefined, + removalFails: boolean, +): void { + if (removalFails) { + assert.ok(retirement?.status === 'retained'); + assert.equal(retirement.reason, 'retirement-unconfirmed'); + assert.ok(retirement.termination?.status === 'exited'); + assert.equal(retirement.termination.mode, 'forced'); + } else { + assert.ok(retirement?.status === 'retired'); + assert.equal(retirement.termination.mode, 'forced'); + } +} + +for (const transport of ['socket', 'http'] as const) { + for (const removalFails of [false, true]) { + test(`${transport} timeout awaits force exit when metadata removal ${removalFails ? 'fails' : 'succeeds'}`, async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + mockRunCmdSync.mockReturnValue({ exitCode: 1, stdout: '', stderr: '' }); + const endpoint = await (transport === 'socket' + ? startHangingSocketServer() + : startHangingHttpServer()); + const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-timeout-owner-')); + const child = spawnRegisteredDaemonFixture( + paths, + { + ...(transport === 'socket' ? { socketPort: endpoint.port } : { httpPort: endpoint.port }), + token: 'test-token', + version: 'test', + codeOrigin: 'checkout', + codeSignature: 'test', + }, + undefined, + ); + let exited = false; + void child.exited.then(() => { + exited = true; + }); + let killRequested!: () => void; + const requested = new Promise((resolve) => { + killRequested = resolve; + }); + const actualKill = process.kill.bind(process); + let settled = false; + let outcome: Promise | undefined; + const kill = vi.spyOn(process, 'kill').mockImplementation((pid, signal) => { + if (pid === child.pid && signal === 'SIGKILL') { + killRequested(); + return true; + } + return actualKill(pid, signal); + }); + const actualUnlink = fs.unlinkSync.bind(fs); + const remove = vi.spyOn(fs, 'unlinkSync').mockImplementation((file) => { + if (removalFails && file === paths.infoPath) + throw Object.assign(new Error('retained registration control'), { code: 'EACCES' }); + actualUnlink(file); + }); + try { + const info = await waitForRegisteredDaemonFixture(paths, child); + outcome = withDiagnosticsScope( + { debug: true, logPath: path.join(paths.baseDir, 'timeout-diagnostics.ndjson') }, + () => + sendRequest( + info, + { ...buildRequest(undefined), command: 'open' }, + transport, + paths, + TIMEOUT_MS, + ), + ).then( + () => assert.fail('hanging request unexpectedly succeeded'), + (error: unknown) => { + settled = true; + return error; + }, + ); + await waitForForceStop(requested); + await sleep(30); + assert.equal(actualKill(child.pid, 0), true); + assert.equal(settled, false, 'request must remain pending while the daemon is alive'); + kill.mockRestore(); + actualKill(child.pid, 'SIGKILL'); + await child.exited; + const error = await outcome; + assert.ok(error instanceof AppError); + assert.equal(normalizeError(error).details?.reason, 'daemon_transport_timeout'); + assertForcedRetirement( + error.details?.retirement as DaemonRetirementResult | undefined, + removalFails, + ); + assert.equal(fs.existsSync(paths.infoPath), removalFails); + assert.equal(fs.existsSync(paths.lockPath), false); + assert.equal(fs.existsSync(paths.baseDir), true); + assert.equal(timeoutDiagnostic(paths).daemonPreservedAfterTimeout, false); + assert.equal(timeoutDiagnostic(paths).daemonPidForceKilled, true); + assert.equal(mockRunCmdSync.mock.calls.filter(([cmd]) => cmd === 'pkill').length, 3); + } finally { + remove.mockRestore(); + kill.mockRestore(); + if (!exited) actualKill(child.pid, 'SIGKILL'); + await child.exited; + await outcome; + await finishRegisteredDaemonFixture(paths.baseDir); + await closeLoopbackServer(endpoint.server); + } + }); + } +} + +test('timeout retains a live registration without captured birth proof and reports that outcome', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; mockRunCmdSync.mockReturnValue({ exitCode: 1, stdout: '', stderr: '' }); - mockIsDaemon.mockReturnValue(true); - mockStop.mockResolvedValue({ status: 'retained', reason: 'exit-timeout' }); - vi.spyOn(process, 'kill').mockImplementation(() => { - throw Object.assign(new Error('refused'), { code: 'EPERM' }); - }); - const { server, port } = await startHangingSocketServer(); + const endpoint = await startHangingHttpServer(); + const paths = resolveDaemonPaths(mkdtempForTestSync('agent-device-timeout-retained-')); + const child = spawnRegisteredDaemonFixture( + paths, + { + httpPort: endpoint.port, + token: 'test-token', + version: 'test', + codeOrigin: 'checkout', + codeSignature: 'test', + }, + undefined, + ); + const kill = vi.spyOn(process, 'kill'); try { + const info = await waitForRegisteredDaemonFixture(paths, child); + const before = fs.readFileSync(paths.infoPath, 'utf8'); + const lockBefore = fs + .readdirSync(paths.lockPath) + .map((name) => [name, fs.readFileSync(path.join(paths.lockPath, name), 'utf8')]); await assert.rejects( - sendRequest( - { port, pid: 7, token: 'test-token', processStartTime: 'start' }, - { ...buildRequest(undefined), command: 'open' }, - 'socket', - dummyStatePaths(), - TIMEOUT_MS, + withDiagnosticsScope( + { debug: true, logPath: path.join(paths.baseDir, 'timeout-diagnostics.ndjson') }, + () => + sendRequest( + { ...info, processStartTime: undefined }, + { ...buildRequest(undefined), command: 'open' }, + 'http', + paths, + TIMEOUT_MS, + ), ), (error: unknown) => { assert.ok(error instanceof AppError); - assert.equal(error.details?.reason, 'daemon_transport_timeout'); + assert.equal(normalizeError(error).details?.reason, 'daemon_transport_timeout'); + const retirement = error.details?.retirement as DaemonRetirementResult | undefined; + assert.ok(retirement?.status === 'retained'); + assert.ok(retirement.termination?.status === 'retained'); + assert.equal(retirement.termination.reason, 'missing-start-time'); + assert.match(normalizeError(error).hint ?? '', /State was retained/); + assert.doesNotMatch(normalizeError(error).hint ?? '', /daemon was reset/); return true; }, ); - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(mockStop.mock.calls.length, 1); + assert.equal(timeoutDiagnostic(paths).daemonPreservedAfterTimeout, true); + assert.equal(process.kill(child.pid, 0), true); + assert.equal(fs.readFileSync(paths.infoPath, 'utf8'), before); + assert.deepEqual( + fs + .readdirSync(paths.lockPath) + .map((name) => [name, fs.readFileSync(path.join(paths.lockPath, name), 'utf8')]), + lockBefore, + ); + assert.equal( + kill.mock.calls.some(([pid, signal]) => pid === child.pid && signal !== 0), + false, + ); } finally { - server.close(); + kill.mockRestore(); + await finishRegisteredDaemonFixture(paths.baseDir); + await closeLoopbackServer(endpoint.server); } }); diff --git a/src/daemon-client/__tests__/daemon-client-transport.test.ts b/src/daemon-client/__tests__/daemon-client-transport.test.ts index d9b60a8bd8..d9860c241a 100644 --- a/src/daemon-client/__tests__/daemon-client-transport.test.ts +++ b/src/daemon-client/__tests__/daemon-client-transport.test.ts @@ -1,6 +1,9 @@ import assert from 'node:assert/strict'; import http from 'node:http'; +import net from 'node:net'; import { test, vi } from 'vitest'; +import * as hostTransport from '@agent-device/host-kit/transport'; +import { sleep } from '@agent-device/host-kit/retry'; import { AppError } from '@agent-device/kernel/errors'; import { DAEMON_HTTP_INSTANCE_HEADER, @@ -36,6 +39,64 @@ function sendWithStaleInstance(port: number, timeoutMs: number) { ); } +test('auto health probing reserves time for a healthy fallback when HTTP hangs', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + const httpServer = http.createServer(() => {}); + const socketServer = net.createServer((socket) => socket.on('error', () => {})); + try { + const httpPort = await listenOnLoopback(httpServer); + const port = await listenOnLoopback(socketServer); + assert.equal( + await canConnect({ token: 'secret', pid: 1, transport: 'http', httpPort, port }, 'auto', 120), + true, + ); + } finally { + await closeLoopbackServer(httpServer); + await closeLoopbackServer(socketServer); + } +}); + +test('the health deadline includes requester loading and forbids a late request', async (t) => { + if (await skipWhenLoopbackUnavailable(t)) return; + let requests = 0; + const server = http.createServer((_req, res) => { + requests += 1; + res.end('{}'); + }); + let release!: () => void; + const blocked = new Promise((resolve) => { + release = resolve; + }); + const actualLoad = hostTransport.loadNodeHttpRequester; + const load = vi + .spyOn(hostTransport, 'loadNodeHttpRequester') + .mockImplementation(async (protocol) => { + await blocked; + return actualLoad(protocol); + }); + let probing: Promise | undefined; + try { + const httpPort = await listenOnLoopback(server); + let settled = false; + probing = canConnect({ token: 'secret', pid: 1, httpPort }, 'http', 40).then((reachable) => { + settled = true; + return reachable; + }); + await sleep(90); + assert.equal(settled, true, 'loading must not extend the probe deadline'); + assert.equal(await probing, false); + release(); + await blocked; + await sleep(10); + assert.equal(requests, 0, 'a timed-out loader must not open a request later'); + } finally { + release(); + await probing; + load.mockRestore(); + await closeLoopbackServer(server); + } +}); + test('persistent remote client caches health and retries a refused stale instance before dispatch', async (t) => { if (await skipWhenLoopbackUnavailable(t)) return; const paths: string[] = []; diff --git a/src/daemon-client/__tests__/daemon-client.test.ts b/src/daemon-client/__tests__/daemon-client.test.ts index f579a3c4a5..cca31e8a26 100644 --- a/src/daemon-client/__tests__/daemon-client.test.ts +++ b/src/daemon-client/__tests__/daemon-client.test.ts @@ -1,5 +1,5 @@ import type { RequestProgressEvent } from '@agent-device/contracts/progress'; -import { test, vi } from 'vitest'; +import { test } from 'vitest'; import assert from 'node:assert/strict'; import http from 'node:http'; import net from 'node:net'; @@ -11,57 +11,18 @@ import { listenOnLoopback, supportsLoopbackBind, } from '../../__tests__/test-utils/loopback.ts'; -import { runCmdBackground } from '@agent-device/host-kit/command'; -import { - isProcessAlive, - readProcessCommand, - readProcessStartTime, - waitForProcessExit, -} from '@agent-device/host-kit/process'; +import { readProcessStartTime } from '@agent-device/host-kit/process'; import { sendToDaemon } from '../daemon-client.ts'; import { currentDaemonCodeSignature } from '../../__tests__/test-utils/daemon-http-fixture.ts'; import { computeDaemonCodeSignature } from '@agent-device/host-kit/code-signature'; import { downloadRemoteArtifact } from '../../remote/daemon-artifacts.ts'; -import { - cleanupFailedDaemonStartupMetadata, - resolveDaemonStartupHint, -} from '../daemon-client-metadata.ts'; +import { resolveDaemonStartupHint } from '../daemon-client-metadata.ts'; import { canConnectSocket } from '../daemon-client-transport.ts'; import { DAEMON_RPC_PROTOCOL_VERSION } from '@agent-device/contracts/daemon-http'; import { resolveDaemonPaths } from '../../daemon-resolution.ts'; import { readVersion } from '@agent-device/host-kit/version'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; -// readProcessStartTime/readProcessCommand shell out to `ps` with a 1s -// timeout (see host-process.ts). isAgentDeviceDaemonProcess re-reads both for -// every liveness check, so a spawned-daemon fixture that is proven live once -// (a real read, right after the process starts) can still be misclassified -// as dead later if a *subsequent* `ps` call happens to miss its deadline -// under full-suite CPU contention. mockReadProcessStartTime/mockReadProcessCommand -// default to `undefined`, which falls through to the real implementation for -// every pid in every test in this file; only the one test below that needs a -// stable answer for its spawned pid configures an override, and clears it -// afterward. -const { mockReadProcessStartTime, mockReadProcessCommand } = vi.hoisted(() => ({ - mockReadProcessStartTime: vi.fn<(pid: number) => string | null | undefined>(), - mockReadProcessCommand: vi.fn<(pid: number) => string | null | undefined>(), -})); - -vi.mock('@agent-device/host-kit/process', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - readProcessStartTime: (pid: number) => { - const overridden = mockReadProcessStartTime(pid); - return overridden !== undefined ? overridden : actual.readProcessStartTime(pid); - }, - readProcessCommand: (pid: number) => { - const overridden = mockReadProcessCommand(pid); - return overridden !== undefined ? overridden : actual.readProcessCommand(pid); - }, - }; -}); - type MockHttpResponse = EventEmitter & { headers?: Record; statusCode?: number; @@ -179,181 +140,19 @@ function writeCurrentDaemonInfo( ); } -test('resolveDaemonStartupHint prefers stale lock guidance when lock exists without info', () => { - const hint = resolveDaemonStartupHint({ hasInfo: false, hasLock: true }); - assert.match(hint, /daemon\.lock/i); - assert.match(hint, /automatically/i); - assert.match(hint, /rm -f '.+daemon\.json' '.+daemon\.lock'/); -}); - -test('resolveDaemonStartupHint covers stale info+lock pair', () => { - const hint = resolveDaemonStartupHint({ hasInfo: true, hasLock: true }); - assert.match(hint, /daemon\.json/i); - assert.match(hint, /daemon\.lock/i); - assert.match(hint, /rm -f '.+daemon\.json' '.+daemon\.lock'/); -}); - -test('resolveDaemonStartupHint falls back to daemon.json guidance', () => { - const hint = resolveDaemonStartupHint({ hasInfo: true, hasLock: false }); - assert.match(hint, /daemon\.json/i); - assert.match(hint, /rm -f '.+daemon\.json' '.+daemon\.lock'/); -}); - -test('resolveDaemonStartupHint includes configured state directory paths', () => { - const paths = resolveDaemonPaths('/tmp/ad-custom-state'); - const hint = resolveDaemonStartupHint({ hasInfo: false, hasLock: true }, paths); - assert.match(hint, /\/tmp\/ad-custom-state\/daemon\.lock/); - assert.match(hint, /\/tmp\/ad-custom-state\/daemon\.json/); - assert.match( - hint, - /rm -f '\/tmp\/ad-custom-state\/daemon\.json' '\/tmp\/ad-custom-state\/daemon\.lock'/, - ); -}); - -test('resolveDaemonStartupHint shell-quotes cleanup paths', () => { +test('startup recovery guidance retains configured paths and requires stopping every user', () => { const paths = resolveDaemonPaths("/tmp/ad custom's state"); - const hint = resolveDaemonStartupHint({ hasInfo: true, hasLock: true }, paths); - assert.match( - hint, - /rm -f '\/tmp\/ad custom'\\''s state\/daemon\.json' '\/tmp\/ad custom'\\''s state\/daemon\.lock'/, - ); -}); - -test('cleanupFailedDaemonStartupMetadata removes partial startup metadata', async () => { - const stateDir = mkdtempForTestSync('agent-device-daemon-cleanup-'); - const paths = resolveDaemonPaths(stateDir); - try { - fs.mkdirSync(paths.baseDir, { recursive: true }); - fs.writeFileSync(paths.infoPath, '{"invalid":true}\n', 'utf8'); - fs.writeFileSync(paths.lockPath, 'not-json\n', 'utf8'); - - const result = await cleanupFailedDaemonStartupMetadata(paths, 'startup_timeout'); - - assert.deepEqual(result, { - reason: 'startup_timeout', - removedInfo: true, - removedLock: true, - stoppedInfoProcess: false, - stoppedLockProcess: false, - }); - assert.equal(fs.existsSync(paths.infoPath), false); - assert.equal(fs.existsSync(paths.lockPath), false); - } finally { - fs.rmSync(stateDir, { recursive: true, force: true }); - } -}); - -test('cleanupFailedDaemonStartupMetadata retains live startup daemon on timeout', async (t) => { - const stateDir = mkdtempForTestSync('agent-device-daemon-live-cleanup-'); - const root = mkdtempForTestSync('agent-device-live-daemon-'); - const daemonDir = path.join(root, 'agent-device', 'dist', 'src', 'internal'); - const daemonScriptPath = path.join(daemonDir, 'daemon.js'); - fs.mkdirSync(daemonDir, { recursive: true }); - fs.writeFileSync(daemonScriptPath, 'setInterval(() => {}, 1000);\n', 'utf8'); - const daemonProcess = runCmdBackground(process.execPath, [daemonScriptPath], { - stdio: 'ignore', - allowFailure: true, - captureOutput: false, - }); - void daemonProcess.wait.catch(() => {}); - const pid = daemonProcess.child.pid; - assert.ok(pid, 'spawned child should have a pid'); - - try { - await new Promise((resolve) => setTimeout(resolve, 50)); - // Read the spawned daemon's real identity once (ground truth: it is - // genuinely alive, with this real start time and command line), then - // pin readProcessStartTime/readProcessCommand to keep returning these - // same proven-real values for this pid. isAgentDeviceDaemonProcess reads - // both again internally on every call inside cleanupFailedDaemonStartupMetadata; - // without pinning, a second real `ps` call could miss its 1s timeout - // under load and misclassify this genuinely-live daemon as dead. - const processStartTime = readProcessStartTime(pid) ?? undefined; - const command = readProcessCommand(pid); - if (command === null || processStartTime === undefined) { - t.skip('process command/start inspection is unavailable in this environment'); - return; - } - mockReadProcessStartTime.mockImplementation((queriedPid: number) => - queriedPid === pid ? processStartTime : undefined, - ); - mockReadProcessCommand.mockImplementation((queriedPid: number) => - queriedPid === pid ? command : undefined, - ); - - const paths = resolveDaemonPaths(stateDir); - fs.mkdirSync(paths.baseDir, { recursive: true }); - fs.writeFileSync( - paths.infoPath, - `${JSON.stringify({ - token: 'startup-secret', - port: 65530, - transport: 'socket', - pid, - processStartTime, - })}\n`, - 'utf8', - ); - fs.writeFileSync( - paths.lockPath, - `${JSON.stringify({ pid, processStartTime, startedAt: Date.now() })}\n`, - 'utf8', - ); - - const result = await cleanupFailedDaemonStartupMetadata(paths, 'startup_timeout', { - stopLiveProcesses: false, - }); - - assert.equal(result.retainedInfoProcess, true); - assert.equal(result.retainedLockProcess, true); - assert.equal(result.removedInfo, false); - assert.equal(result.removedLock, false); - assert.equal(isProcessAlive(pid), true); - assert.equal(fs.existsSync(paths.infoPath), true); - assert.equal(fs.existsSync(paths.lockPath), true); - } finally { - mockReadProcessStartTime.mockReset(); - mockReadProcessCommand.mockReset(); - if (isProcessAlive(pid)) { - process.kill(pid, 'SIGKILL'); - await waitForProcessExit(pid, 1_500); - } - fs.rmSync(stateDir, { recursive: true, force: true }); - fs.rmSync(root, { recursive: true, force: true }); - } -}); - -test('cleanupFailedDaemonStartupMetadata removes stale daemon metadata on timeout', async () => { - const stateDir = mkdtempForTestSync('agent-device-daemon-stale-cleanup-'); - const paths = resolveDaemonPaths(stateDir); - try { - fs.mkdirSync(paths.baseDir, { recursive: true }); - fs.writeFileSync( - paths.infoPath, - `${JSON.stringify({ - token: 'startup-secret', - port: 65530, - transport: 'socket', - pid: 999_999, - })}\n`, - 'utf8', - ); - fs.writeFileSync( - paths.lockPath, - `${JSON.stringify({ pid: 999_999, startedAt: Date.now() })}\n`, - 'utf8', - ); - - const result = await cleanupFailedDaemonStartupMetadata(paths, 'startup_timeout', { - stopLiveProcesses: false, - }); - - assert.equal(result.removedInfo, true); - assert.equal(result.removedLock, true); - assert.equal(fs.existsSync(paths.infoPath), false); - assert.equal(fs.existsSync(paths.lockPath), false); - } finally { - fs.rmSync(stateDir, { recursive: true, force: true }); + for (const state of [ + { hasInfo: false, hasLock: true }, + { hasInfo: true, hasLock: true }, + { hasInfo: true, hasLock: false }, + { hasInfo: false, hasLock: false }, + ]) { + const hint = resolveDaemonStartupHint(state, paths); + if (state.hasInfo) assert.ok(hint.includes(paths.infoPath)); + if (state.hasLock) assert.ok(hint.includes(paths.lockPath)); + assert.match(hint, /stop all older clients and daemons/); + assert.doesNotMatch(hint, /rm -f/); } }); diff --git a/src/daemon-client/daemon-client-lifecycle.ts b/src/daemon-client/daemon-client-lifecycle.ts index 43a6556781..1d76d43ecf 100644 --- a/src/daemon-client/daemon-client-lifecycle.ts +++ b/src/daemon-client/daemon-client-lifecycle.ts @@ -1,17 +1,26 @@ import fs from 'node:fs'; import net from 'node:net'; -import os from 'node:os'; -import path from 'node:path'; -import { AppError, normalizeError } from '@agent-device/kernel/errors'; +import { AppError, normalizeError, type NormalizedError } from '@agent-device/kernel/errors'; import { readReplayDivergenceResume } from '@agent-device/ad-replay/divergence'; import type { DaemonRequest, DaemonResponse } from '../daemon/daemon-request.ts'; -import { runCmdDetachedMonitored, type ExecDetachedExit } from '@agent-device/host-kit/command'; +import { type ExecDetachedExit } from '@agent-device/host-kit/command'; import { shellQuoteIfNeeded } from '@agent-device/kernel/device-shell'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; -import { isProcessAlive, readProcessStartTime } from '@agent-device/host-kit/process'; +import { isProcessAlive } from '@agent-device/host-kit/process'; import { sleep } from '@agent-device/host-kit/retry'; +import { inspectProcessLock, type ProcessLockInspection } from '@agent-device/host-kit/file'; -import { findUnrecoveredRepairCommitFailure } from '../session-repair-tombstone.ts'; +import type { findUnrecoveredRepairCommitFailure } from '../session-repair-tombstone.ts'; +import { + DAEMON_STARTUP_EXIT_CODES, + createOwnedReplayStateDir, + recoverAbandonedDaemonRegistration, + type DaemonRetirementResult, + launchDaemonProcess, + stopAndRetireDaemon, + type OwnedReplayStateDir, + type DaemonStartupLaunch, +} from '../daemon-registration-owner.ts'; import { resolveDaemonPaths, resolveDaemonServerMode, @@ -28,19 +37,11 @@ import { import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; import { - cleanupFailedDaemonStartupMetadata, - cleanupStaleDaemonLockIfSafe, getDaemonMetadataState, - isDaemonLockHeldByAnotherDaemon, isRemoteDaemon, readDaemonInfo, - recoverDaemonLockHolder, - removeDaemonInfo, - removeDaemonLock, resolveDaemonStartupHint, - stopDaemonProcessForTakeover, type DaemonInfo, - type DaemonStartupCleanupResult, } from './daemon-client-metadata.ts'; import { canConnect, @@ -52,7 +53,7 @@ export type DaemonClientSettings = { paths: DaemonPaths; transportPreference: DaemonTransportPreference; serverMode: DaemonServerMode; - ownedStateDir?: boolean; + ownedStateDir?: OwnedReplayStateDir; remoteBaseUrl?: string; remoteAuthToken?: string; }; @@ -62,19 +63,14 @@ export type EnsuredDaemon = { startedByClient: boolean; }; -type DaemonStartupLaunch = { - pid: number; - /** The launched process's start time, so a reused pid is never taken for it. */ - startTime?: string; - exited: Promise; -}; - type DaemonStartupWaitResult = | { kind: 'ready'; daemon: EnsuredDaemon } | { kind: 'early_exit'; exit: ExecDetachedExit } - | { kind: 'timeout' }; + | { kind: 'unproven'; error: AppError } + | { kind: 'retry' | 'timeout' }; const DAEMON_STARTUP_TIMEOUT_MS = 15_000; +const MINIMUM_DAEMON_TAKEOVER_BUDGET_MS = 5_000; const LIVE_DAEMON_PROBE_RETRIES = 3; const LIVE_DAEMON_PROBE_RETRY_DELAY_MS = 200; const DAEMON_STARTUP_ATTEMPTS = 2; @@ -91,10 +87,11 @@ export function resolveClientSettings( const explicitStateDir = resolveExplicitStateDir(req); const remote = resolveRemoteClientSettings(req, suppliedAuthToken); const transport = resolveTransportClientSettings(req, remote.remoteBaseUrl); - const ownedStateDir = shouldUseOwnedReplayStateDir(req, explicitStateDir, remote.rawBaseUrl); - const stateDir = ownedStateDir ? createOwnedReplayStateDir() : explicitStateDir; + const ownedStateDir = shouldUseOwnedReplayStateDir(req, explicitStateDir, remote.rawBaseUrl) + ? createOwnedReplayStateDir() + : undefined; return { - paths: resolveDaemonPaths(stateDir), + paths: ownedStateDir?.paths ?? resolveDaemonPaths(explicitStateDir), transportPreference: transport.preference, serverMode: transport.serverMode, ownedStateDir, @@ -153,10 +150,6 @@ function shouldUseOwnedReplayStateDir( return isOneShotReplayCommand(req.command) && !explicitStateDir && !rawRemoteBaseUrl; } -function createOwnedReplayStateDir(): string { - return fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-daemon-')); -} - export async function ensureDaemon(settings: DaemonClientSettings): Promise { if (settings.remoteBaseUrl) { return await ensureRemoteDaemon(settings); @@ -172,7 +165,6 @@ async function ensureLocalDaemon(settings: DaemonClientSettings): Promise { +async function readReusableLocalDaemon( + settings: DaemonClientSettings, + options: { deadline?: number; waitForLiveStartup?: boolean } = {}, +): Promise { + const { deadline } = options; + const inspection = inspectProcessLock(settings.paths.lockPath); + if (inspection.state === 'unproven') throw daemonRegistrationUnprovenError(settings, inspection); const existing = readDaemonInfo(settings.paths.infoPath); if (!existing) return null; + if (!registrationAllowsDaemonObservation(inspection, existing)) return null; + if ( + options.waitForLiveStartup && + isProcessAlive(existing.pid) && + !(await canConnect(existing, 'auto', remainingStartupBudget(deadline))) + ) + return null; const decision = await resolveDaemonTakeover(existing, { - onClientTransport: () => canReachReusableDaemon(existing, settings.transportPreference), - onAnyAdvertisedTransport: () => canReachReusableDaemon(existing, 'auto'), + onClientTransport: () => + canReachReusableDaemon(existing, settings.transportPreference, deadline), + onAnyAdvertisedTransport: () => canReachReusableDaemon(existing, 'auto', deadline), }); if (decision.kind === 'reuse') return existing; if (decision.kind === 'refuseNewer') { throw newerDaemonRefusedError(existing, decision, settings.paths.baseDir); } + if (remainingStartupBudget(deadline) < MINIMUM_DAEMON_TAKEOVER_BUDGET_MS) return null; emitDaemonTakeoverNotice(existing, decision.reason, settings.paths.baseDir); - await stopDaemonProcessForTakeover(existing); - removeDaemonInfo(settings.paths.infoPath); + await retireDaemonForTakeover(existing, settings.paths); return null; } +function registrationAllowsDaemonObservation( + inspection: ProcessLockInspection, + info: DaemonInfo, +): boolean { + return ( + inspection.state === 'absent' || + (inspection.state === 'held' && + inspection.owner.pid === info.pid && + inspection.owner.startTime === (info.processStartTime ?? null)) + ); +} + +function daemonRegistrationUnprovenError( + settings: DaemonClientSettings, + inspection?: ProcessLockInspection, +): AppError { + return new AppError('COMMAND_FAILED', 'Daemon registration ownership could not be verified.', { + reason: 'daemon_registration_unproven', + inspection, + stateDir: settings.paths.baseDir, + hint: resolveDaemonStartupHint(getDaemonMetadataState(settings.paths), settings.paths), + }); +} + +async function retireDaemonForTakeover(existing: DaemonInfo, paths: DaemonPaths): Promise { + const retirement = await stopAndRetireDaemon({ + paths: paths, + observed: { pid: existing.pid, startTime: existing.processStartTime ?? null }, + mode: 'graceful', + }); + if (retirement.status === 'retained') { + throw new AppError('COMMAND_FAILED', 'Daemon replacement could not be confirmed.', { + reason: 'daemon_retirement_unconfirmed', + retirement, + hint: + retirement.error?.hint ?? resolveDaemonStartupHint(getDaemonMetadataState(paths), paths), + }); + } +} + /** * A daemon whose pid is still alive is probed again before it can be judged unreachable. A probe's * budget is wall-clock time on this client's event loop, so a client that stalls past it (a large @@ -226,12 +272,14 @@ async function readReusableLocalDaemon(settings: DaemonClientSettings): Promise< async function canReachReusableDaemon( info: DaemonInfo, preference: DaemonTransportPreference, + deadline?: number, ): Promise { - if (await canConnectReusableDaemon(info, preference)) return true; + if (await canConnectReusableDaemon(info, preference, deadline)) return true; for (let retry = 1; retry <= LIVE_DAEMON_PROBE_RETRIES; retry += 1) { - if (!isProcessAlive(info.pid)) return false; - await sleep(LIVE_DAEMON_PROBE_RETRY_DELAY_MS); - if (await canConnectReusableDaemon(info, preference)) { + if (!isProcessAlive(info.pid) || (deadline !== undefined && Date.now() >= deadline)) + return false; + await sleep(Math.min(LIVE_DAEMON_PROBE_RETRY_DELAY_MS, remainingStartupBudget(deadline))); + if (await canConnectReusableDaemon(info, preference, deadline)) { emitDiagnostic({ level: 'warn', phase: 'daemon_probe_recovered', @@ -267,9 +315,10 @@ async function assertDaemonPolicyMatches(existing: DaemonInfo, stateDir: string) async function canConnectReusableDaemon( info: DaemonInfo, preference: DaemonTransportPreference, + deadline?: number, ): Promise { try { - return await canConnect(info, preference); + return await canConnect(info, preference, remainingStartupBudget(deadline)); } catch (error) { if (isDaemonTransportUnavailableError(error)) return false; throw error; @@ -303,65 +352,29 @@ function emitDaemonTakeoverNotice(info: DaemonInfo, reason: string, stateDir: st } } -async function startLocalDaemon(settings: DaemonClientSettings): Promise { - let lockRecoveryCount = 0; - const cleanupResults: DaemonStartupCleanupResult[] = []; - let startError: string | undefined; - let daemonProcess: ExecDetachedExit | { pid: number } | undefined; - for (let attempt = 1; attempt <= DAEMON_STARTUP_ATTEMPTS; attempt += 1) { - let launch: DaemonStartupLaunch; - try { - launch = startDaemon(settings); - daemonProcess = { pid: launch.pid }; - } catch (error) { - startError = error instanceof Error ? error.message : String(error); - cleanupResults.push(await cleanupFailedDaemonStartupMetadata(settings.paths, 'start_error')); - if (attempt < DAEMON_STARTUP_ATTEMPTS) { - await sleep(150); - continue; - } - break; - } - - const startup = await waitForDaemonStartup(DAEMON_STARTUP_TIMEOUT_MS, settings, launch); - if (startup.kind === 'ready') return startup.daemon; - if (startup.kind === 'early_exit') { - daemonProcess = startup.exit; - startError = describeDaemonEarlyExit(startup.exit); - cleanupResults.push(await cleanupFailedDaemonStartupMetadata(settings.paths, 'start_error')); - if (attempt < DAEMON_STARTUP_ATTEMPTS) { - await sleep(150); - continue; - } - break; - } - - if (await recoverDaemonLockHolder(settings.paths)) { - lockRecoveryCount += 1; - continue; - } - - const metadataState = getDaemonMetadataState(settings.paths); - const hasAnotherAttempt = attempt < DAEMON_STARTUP_ATTEMPTS; - const cleanup = await cleanupFailedDaemonStartupMetadata(settings.paths, 'startup_timeout', { - stopLiveProcesses: false, - }); - cleanupResults.push(cleanup); - if (cleanup.retainedInfoProcess || cleanup.retainedLockProcess) { - const extended = await waitForDaemonStartup(DAEMON_STARTUP_TIMEOUT_MS, settings, launch); - if (extended.kind === 'ready') return extended.daemon; - if (extended.kind === 'early_exit') { - daemonProcess = extended.exit; - startError = describeDaemonEarlyExit(extended.exit); - } - break; - } - if (!hasAnotherAttempt) break; +type FailedDaemonStartup = { + cleanup?: DaemonRetirementResult; + startError?: string; + startupError?: NormalizedError; + daemonProcess?: ExecDetachedExit | { pid: number }; + retry: boolean; +}; - // Detached daemon startup can race on busy CI hosts; retry when no metadata exists yet. - if (!metadataState.hasInfo && !metadataState.hasLock) await sleep(150); +async function startLocalDaemon(settings: DaemonClientSettings): Promise { + const deadline = Date.now() + DAEMON_STARTUP_TIMEOUT_MS; + const cleanupResults: DaemonRetirementResult[] = []; + let failure: FailedDaemonStartup | undefined; + let attempts = 0; + while (attempts < DAEMON_STARTUP_ATTEMPTS && Date.now() < deadline) { + attempts += 1; + const result = await attemptLocalDaemonStartup(settings, deadline); + if ('daemon' in result) return result.daemon; + failure = result; + if (result.cleanup) cleanupResults.push(result.cleanup); + if (!result.retry) break; + await sleep(Math.min(150, Math.max(0, deadline - Date.now()))); } - + const { startError, startupError, daemonProcess }: Partial = failure ?? {}; const state = getDaemonMetadataState(settings.paths); const daemonLogTail = readRecentLogTail(settings.paths.logPath); throw new AppError('COMMAND_FAILED', 'Failed to start daemon', { @@ -371,10 +384,10 @@ async function startLocalDaemon(settings: DaemonClientSettings): Promise { + let launch: DaemonStartupLaunch; + try { + launch = startDaemon(settings); + } catch (error) { + const cleanup = await recoverAbandonedDaemonRegistration({ + paths: settings.paths, + observed: null, + lockTimeoutMs: 0, + }); + return { + cleanup, + startError: normalizeError(error).message, + retry: cleanup.status !== 'retained', + }; + } + const startup = await waitForDaemonStartup(deadline, settings, launch); + if (startup.kind === 'ready') return { daemon: startup.daemon }; + if (startup.kind === 'retry') return { retry: true }; + if (startup.kind === 'unproven') { + const startupError = normalizeError(startup.error); + return { + retry: false, + startError: startupError.message, + startupError, + daemonProcess: { pid: launch.pid }, + }; + } + const { cleanup, joined } = await retireStartupAttempt(settings, launch, deadline); + const available = isRegistrationAvailable(inspectProcessLock(settings.paths.lockPath)); + return { + cleanup, + retry: joined && startup.kind === 'early_exit' && available, + startError: startup.kind === 'early_exit' ? describeDaemonEarlyExit(startup.exit) : undefined, + daemonProcess: startup.kind === 'early_exit' ? startup.exit : { pid: launch.pid }, + }; +} + +async function retireStartupAttempt( + settings: DaemonClientSettings, + launch: DaemonStartupLaunch, + deadline: number, + ownedStateDir?: OwnedReplayStateDir, +): Promise<{ cleanup: DaemonRetirementResult; joined: boolean }> { + const cleanup = await stopAndRetireDaemon({ + paths: settings.paths, + observed: { pid: launch.pid, startTime: launch.startTime ?? null }, + mode: 'graceful', + ownedStateDir, + termTimeoutMs: Math.min(3_000, remainingStartupBudget(deadline)), + killTimeoutMs: 1_000, + lockTimeoutMs: 0, + }); + const joined = await joinStartup(launch); + return { cleanup, joined }; +} + +async function joinStartup(launch: DaemonStartupLaunch): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + launch.exited.then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), 1_000); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +function remainingStartupBudget(deadline?: number): number { + return deadline === undefined ? Number.POSITIVE_INFINITY : Math.max(0, deadline - Date.now()); +} + +function isRegistrationAvailable(inspection: ProcessLockInspection): boolean { + return ( + inspection.state === 'absent' || + (inspection.state === 'held' && + (inspection.liveness === 'owner-process-dead' || + inspection.liveness === 'owner-process-reused')) + ); +} + /** * ADR 0012 decision 6 (BLOCKER 2, third follow-up): a one-shot repair * (`replay --save-script`) that COMPLETES without diverging returns SUCCESS * here — the actual healed-script COMMIT is deferred to daemon teardown * (`finalizeRepairTeardown`, run inside the daemon process's own shutdown - * handler, triggered by `stopDaemonProcessForTakeover` below). If that + * handler, triggered by `stopAndRetireDaemon`). If that * deferred commit then FAILS, the daemon leaves a `REPAIR_COMMIT_FAILED` * tombstone in this owned state dir — the only surviving record of the * failure, since the daemon process (and its in-memory session) is gone by @@ -435,49 +535,44 @@ export async function cleanupDaemonAfterRequest( return response; } - const result = { - pid: daemon.info.pid, - removedInfo: false, - removedLock: false, - removedStateDir: false, - error: undefined as string | undefined, - }; - let surfacedResponse = response; - - try { - await stopDaemonProcessForTakeover(daemon.info); - } catch (error) { - result.error = error instanceof Error ? error.message : String(error); - } finally { - const infoExists = fs.existsSync(settings.paths.infoPath); - removeDaemonInfo(settings.paths.infoPath); - result.removedInfo = infoExists && !fs.existsSync(settings.paths.infoPath); - - const lockExists = fs.existsSync(settings.paths.lockPath); - removeDaemonLock(settings.paths.lockPath); - result.removedLock = lockExists && !fs.existsSync(settings.paths.lockPath); - - if (settings.ownedStateDir) { - // `stopDaemonProcessForTakeover` above waits for the (real) daemon - // process to actually exit, which only happens AFTER its shutdown - // handler finishes `finalizeRepairTeardown` for every session — so by - // now any commit-failure tombstone it would leave is already on disk. - const unrecovered = findUnrecoveredRepairCommitFailure(settings.paths.sessionsDir); - if (unrecovered) { - surfacedResponse = surfaceUnrecoveredRepairCommitFailure(response, unrecovered); - } else { - fs.rmSync(settings.paths.baseDir, { recursive: true, force: true }); - result.removedStateDir = !fs.existsSync(settings.paths.baseDir); - } - } - } - + const result = await stopAndRetireDaemon({ + paths: settings.paths, + observed: { pid: daemon.info.pid, startTime: daemon.info.processStartTime ?? null }, + mode: 'graceful', + ownedStateDir: settings.ownedStateDir, + }); emitDiagnostic({ - level: result.error ? 'warn' : 'info', + level: result.status === 'retained' ? 'warn' : 'info', phase: 'daemon_replay_cleanup', - data: result, + data: { pid: daemon.info.pid, ...result }, }); - return surfacedResponse; + if (result.status !== 'absent' && result.repairCommitFailure) { + return surfaceUnrecoveredRepairCommitFailure( + response, + result.repairCommitFailure, + result.status === 'retained' ? result.error : undefined, + ); + } + if (result.status === 'retained' && response?.ok) { + return { + ok: false, + error: normalizeError( + new AppError( + 'COMMAND_FAILED', + 'Replay completed, but daemon cleanup could not be confirmed.', + { + reason: 'daemon_retirement_unconfirmed', + retirement: result, + stateDir: settings.paths.baseDir, + hint: + result.error?.hint ?? + `State and diagnostics were retained at ${settings.paths.baseDir}. Resolve the reported cleanup failure before retrying.`, + }, + ), + ), + }; + } + return response; } /** @@ -498,6 +593,7 @@ export async function cleanupDaemonAfterRequest( function surfaceUnrecoveredRepairCommitFailure( response: DaemonResponse | undefined, unrecovered: NonNullable>, + cleanupFailure?: NormalizedError, ): DaemonResponse { if (response && !response.ok) return response; const { sessionName, tombstone } = unrecovered; @@ -507,7 +603,16 @@ function surfaceUnrecoveredRepairCommitFailure( const message = `The repair transaction for session "${sessionName}" completed, but committing its ` + `healed script failed at teardown: ${tombstone.commitFailure.message}. ${reRun}.`; - return { ok: false, error: normalizeError(new AppError('REPAIR_COMMIT_FAILED', message)) }; + return { + ok: false, + error: normalizeError( + new AppError( + 'REPAIR_COMMIT_FAILED', + message, + cleanupFailure ? { cleanupFailure } : undefined, + ), + ), + }; } /** @@ -524,7 +629,7 @@ function surfaceUnrecoveredRepairCommitFailure( * `resume.allowed` (plan-resumability): a held divergence with `allowed: false` * still holds the session so the agent can inspect and `close` cleanly. */ -export function isHeldRepairDivergence(response: DaemonResponse | undefined): boolean { +function isHeldRepairDivergence(response: DaemonResponse | undefined): boolean { if (!response || response.ok) return false; if (response.error.code !== 'REPLAY_DIVERGENCE') return false; const resume = readReplayDivergenceResume(response.error.details?.divergence); @@ -539,7 +644,7 @@ export function isHeldRepairDivergence(response: DaemonResponse | undefined): bo * selector-miss's own guidance) so the agent's next command knows to target * the SAME daemon instead of resolving to the default one. */ -export function attachRepairSessionAddressHint( +function attachRepairSessionAddressHint( response: Extract, stateDir: string, ): Extract { @@ -571,7 +676,7 @@ function isOneShotReplayCommand(command: string | undefined): boolean { * anyway, but the explicit command check keeps that carve-out a decision * rather than an accident of the response shape. */ -export function isActiveReplaySessionResponse( +function isActiveReplaySessionResponse( req: Omit, response: DaemonResponse | undefined, ): boolean { @@ -628,36 +733,102 @@ export function attachActiveSessionAddressHint( } async function waitForDaemonStartup( - timeoutMs: number, + deadline: number, settings: DaemonClientSettings, launch: DaemonStartupLaunch, ): Promise { - const start = Date.now(); let earlyExit: ExecDetachedExit | undefined; void launch.exited.then((exit) => { earlyExit = exit; }); - - while (Date.now() - start < timeoutMs) { - const info = readDaemonInfo(settings.paths.infoPath); - if (info && (await canConnect(info, settings.transportPreference))) { - if (isLaunchedDaemon(info, launch)) { - return { kind: 'ready', daemon: { info, startedByClient: true } }; - } - // Another client's daemon won the start: adopt it only as a reusable daemon would be. An - // incompatible one is replaced, and this wait then sees its own daemon's early exit. - const winner = await readReusableLocalDaemon(settings); - if (winner) return { kind: 'ready', daemon: { info: winner, startedByClient: false } }; + while (Date.now() < deadline) { + if (earlyExit) { + const kind = classifyDaemonStartupExit(earlyExit); + if (kind === 'unproven') + return { kind: 'unproven', error: daemonRegistrationUnprovenError(settings) }; + if (kind === 'failed') return { kind: 'early_exit', exit: earlyExit }; + const contender = await observeContendingDaemon(settings, deadline); + if (contender) return contender; + } else { + const info = await readReadyLaunchedDaemon(settings, launch, deadline); + if (info && !earlyExit) return { kind: 'ready', daemon: { info, startedByClient: true } }; } - // A daemon that lost the startup lock exits cleanly; the daemon that won it is still starting. - if (earlyExit && !isDaemonLockHeldByAnotherDaemon(settings.paths, earlyExit.pid)) { - return { kind: 'early_exit', exit: earlyExit }; - } - await sleep(100); + await sleep(Math.min(100, remainingStartupBudget(deadline))); } return { kind: 'timeout' }; } +function classifyDaemonStartupExit(exit: ExecDetachedExit): 'busy' | 'unproven' | 'failed' { + if (exit.error || exit.signal) return 'failed'; + switch (exit.exitCode) { + case DAEMON_STARTUP_EXIT_CODES.busy: + return 'busy'; + case DAEMON_STARTUP_EXIT_CODES.unproven: + return 'unproven'; + default: + return 'failed'; + } +} + +async function observeContendingDaemon( + settings: DaemonClientSettings, + deadline: number, +): Promise { + let winner: DaemonInfo | null; + try { + winner = await readReusableLocalDaemon(settings, { deadline, waitForLiveStartup: true }); + } catch (error) { + if (error instanceof AppError && error.details?.reason === 'daemon_registration_unproven') + return { kind: 'unproven', error }; + throw error; + } + if (Date.now() >= deadline) return null; + if (winner) return { kind: 'ready', daemon: { info: winner, startedByClient: false } }; + const inspection = inspectProcessLock(settings.paths.lockPath); + if (inspection.state === 'unproven') + return { kind: 'unproven', error: daemonRegistrationUnprovenError(settings, inspection) }; + return isRegistrationAvailable(inspection) ? { kind: 'retry' } : null; +} + +async function readReadyLaunchedDaemon( + settings: DaemonClientSettings, + launch: DaemonStartupLaunch, + deadline: number, +): Promise { + const info = readDaemonInfo(settings.paths.infoPath); + if (!info || !isLaunchedDaemon(info, launch)) return null; + try { + return (await canConnect( + info, + settings.transportPreference, + remainingStartupBudget(deadline), + )) && Date.now() < deadline + ? info + : null; + } catch (error) { + const { cleanup, joined } = await retireStartupAttempt( + settings, + launch, + deadline, + settings.ownedStateDir, + ); + emitDiagnostic({ + level: 'warn', + phase: 'daemon_startup_observation_failed', + data: { stateDir: settings.paths.baseDir, cleanup, joined, error: normalizeError(error) }, + }); + if (error instanceof AppError) { + error.details = { + ...error.details, + stateDir: settings.paths.baseDir, + cleanupResults: [cleanup], + startupJoined: joined, + }; + } + throw error; + } +} + /** Whether `info` names the daemon process this client launched: same pid and start time. */ function isLaunchedDaemon(info: DaemonInfo, launch: DaemonStartupLaunch): boolean { return ( @@ -669,28 +840,14 @@ function isLaunchedDaemon(info: DaemonInfo, launch: DaemonStartupLaunch): boolea function startDaemon(settings: DaemonClientSettings): DaemonStartupLaunch { const launchSpec = resolveDaemonLaunchSpec(); - const args = launchSpec.useSrc - ? ['--experimental-strip-types', launchSpec.srcPath] - : [launchSpec.distPath]; - const env = { - ...process.env, - AGENT_DEVICE_STATE_DIR: settings.paths.baseDir, - AGENT_DEVICE_DAEMON_SERVER_MODE: settings.serverMode, - }; - - fs.mkdirSync(settings.paths.baseDir, { recursive: true }); - const stdoutFd = fs.openSync(settings.paths.logPath, 'a'); - const stderrFd = fs.openSync(settings.paths.logPath, 'a'); - try { - const launched = runCmdDetachedMonitored(process.execPath, args, { - env, - stdio: ['ignore', stdoutFd, stderrFd], - }); - return { ...launched, startTime: readProcessStartTime(launched.pid) ?? undefined }; - } finally { - fs.closeSync(stdoutFd); - fs.closeSync(stderrFd); - } + return launchDaemonProcess({ + paths: settings.paths, + serverMode: settings.serverMode, + ownedStateDir: settings.ownedStateDir, + args: launchSpec.useSrc + ? ['--experimental-strip-types', launchSpec.srcPath] + : [launchSpec.distPath], + }); } function describeDaemonEarlyExit(exit: ExecDetachedExit): string { @@ -771,3 +928,21 @@ function isLoopbackHostname(hostname: string): boolean { if (net.isIPv6(normalized)) return LOOPBACK_BLOCK_LIST.check(normalized, 'ipv6'); return false; } + +export function attachSessionAddressHints( + response: DaemonResponse, + req: Omit, + settings: DaemonClientSettings, +): DaemonResponse { + if (!response.ok) { + return settings.ownedStateDir && isHeldRepairDivergence(response) + ? attachRepairSessionAddressHint(response, settings.paths.baseDir) + : response; + } + return isActiveReplaySessionResponse(req, response) + ? attachActiveSessionAddressHint( + response, + settings.ownedStateDir ? settings.paths.baseDir : undefined, + ) + : response; +} diff --git a/src/daemon-client/daemon-client-metadata.ts b/src/daemon-client/daemon-client-metadata.ts index 3a448b099f..718ed7b1cc 100644 --- a/src/daemon-client/daemon-client-metadata.ts +++ b/src/daemon-client/daemon-client-metadata.ts @@ -1,12 +1,5 @@ import fs from 'node:fs'; -import { AppError } from '@agent-device/kernel/errors'; -import { shellQuote } from '@agent-device/kernel/device-shell'; -import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; -import { - isAgentDeviceDaemonProcess, - stopDaemonProcess, - type DaemonTerminationResult, -} from '../daemon-process.ts'; +import { isProcessPid } from '@agent-device/host-kit/process'; import type { DaemonCodeOrigin } from '@agent-device/host-kit/code-signature'; @@ -33,33 +26,11 @@ export type DaemonInfo = { remoteUpstreamInstanceId?: string; }; -type DaemonLockInfo = { - pid: number; - processStartTime?: string; - startedAt?: number; -}; - export type DaemonMetadataState = { hasInfo: boolean; hasLock: boolean; }; -type DaemonStartupCleanupReason = 'start_error' | 'startup_timeout'; - -export type DaemonStartupCleanupResult = { - reason: DaemonStartupCleanupReason; - removedInfo: boolean; - removedLock: boolean; - stoppedInfoProcess: boolean; - stoppedLockProcess: boolean; - retainedInfoProcess?: boolean; - retainedLockProcess?: boolean; - error?: string; -}; - -const DAEMON_TAKEOVER_TERM_TIMEOUT_MS = 3000; -const DAEMON_TAKEOVER_KILL_TIMEOUT_MS = 1000; - export function readDaemonInfo(infoPath: string): DaemonInfo | null { const data = readJsonFile(infoPath); if (!data || typeof data !== 'object') return null; @@ -72,7 +43,7 @@ export function readDaemonInfo(infoPath: string): DaemonInfo | null { token, ...ports, transport: readDaemonInfoTransport(parsed.transport), - pid: readPositiveInteger(parsed.pid) ?? 0, + pid: isProcessPid(parsed.pid) ? parsed.pid : 0, version: readOptionalString(parsed.version), codeOrigin: readDaemonInfoCodeOrigin(parsed.codeOrigin), codeSignature: readOptionalString(parsed.codeSignature), @@ -110,129 +81,6 @@ function readPositiveInteger(value: unknown): number | undefined { return Number.isInteger(value) && Number(value) > 0 ? Number(value) : undefined; } -function readDaemonLockInfo(lockPath: string): DaemonLockInfo | null { - const data = readJsonFile(lockPath); - if (!data || typeof data !== 'object') return null; - const parsed = data as Partial; - const hasPid = Number.isInteger(parsed.pid) && Number(parsed.pid) > 0; - if (!hasPid) { - return null; - } - return { - pid: Number(parsed.pid), - processStartTime: - typeof parsed.processStartTime === 'string' ? parsed.processStartTime : undefined, - startedAt: typeof parsed.startedAt === 'number' ? parsed.startedAt : undefined, - }; -} - -/** - * Whether a live daemon other than `pid` holds the startup lock: another client's daemon won the - * start, and the daemon at `pid` exited because it lost the lock. - */ -export function isDaemonLockHeldByAnotherDaemon(paths: DaemonPaths, pid: number): boolean { - const lockInfo = readDaemonLockInfo(paths.lockPath); - return ( - lockInfo !== null && - lockInfo.pid !== pid && - isAgentDeviceDaemonProcess(lockInfo.pid, lockInfo.processStartTime) - ); -} - -export function removeDaemonInfo(infoPath: string): void { - removeFileIfExists(infoPath); -} - -export function removeDaemonLock(lockPath: string): void { - removeFileIfExists(lockPath); -} - -export function cleanupStaleDaemonLockIfSafe(paths: DaemonPaths): void { - const state = getDaemonMetadataState(paths); - if (!state.hasLock || state.hasInfo) return; - const lockInfo = readDaemonLockInfo(paths.lockPath); - if (!lockInfo) { - removeDaemonLock(paths.lockPath); - return; - } - if (isAgentDeviceDaemonProcess(lockInfo.pid, lockInfo.processStartTime)) { - return; - } - removeDaemonLock(paths.lockPath); -} - -export async function cleanupFailedDaemonStartupMetadata( - paths: DaemonPaths, - reason: DaemonStartupCleanupReason, - options: { stopLiveProcesses?: boolean } = {}, -): Promise { - const stopLiveProcesses = options.stopLiveProcesses ?? true; - const result: DaemonStartupCleanupResult = { - reason, - removedInfo: false, - removedLock: false, - stoppedInfoProcess: false, - stoppedLockProcess: false, - }; - - try { - const infoExists = fs.existsSync(paths.infoPath); - const info = readDaemonInfo(paths.infoPath); - if (info) { - const liveInfoProcess = isAgentDeviceDaemonProcess(info.pid, info.processStartTime); - if (liveInfoProcess && !stopLiveProcesses) { - result.retainedInfoProcess = true; - } else { - if (liveInfoProcess) { - await stopDaemonProcessForTakeover(info); - result.stoppedInfoProcess = true; - } - removeDaemonInfo(paths.infoPath); - result.removedInfo = true; - } - } else if (infoExists) { - removeDaemonInfo(paths.infoPath); - result.removedInfo = true; - } - - const lockExists = fs.existsSync(paths.lockPath); - const lockInfo = readDaemonLockInfo(paths.lockPath); - if (lockInfo) { - const liveLockProcess = isAgentDeviceDaemonProcess(lockInfo.pid, lockInfo.processStartTime); - if (liveLockProcess && !stopLiveProcesses) { - result.retainedLockProcess = true; - } else { - if (liveLockProcess) { - const termination = await stopDaemonProcess( - { pid: lockInfo.pid, startTime: lockInfo.processStartTime ?? null }, - { - mode: 'graceful', - termTimeoutMs: DAEMON_TAKEOVER_TERM_TIMEOUT_MS, - killTimeoutMs: DAEMON_TAKEOVER_KILL_TIMEOUT_MS, - }, - ); - requireDaemonExit(termination); - result.stoppedLockProcess = true; - } - removeDaemonLock(paths.lockPath); - result.removedLock = true; - } - } else if (lockExists) { - removeDaemonLock(paths.lockPath); - result.removedLock = true; - } - } catch (error) { - result.error = error instanceof Error ? error.message : String(error); - } - - emitDiagnostic({ - level: result.error ? 'warn' : 'info', - phase: 'daemon_startup_metadata_cleanup', - data: result, - }); - return result; -} - export function getDaemonMetadataState(paths: DaemonPaths): DaemonMetadataState { return { hasInfo: fs.existsSync(paths.infoPath), @@ -240,44 +88,6 @@ export function getDaemonMetadataState(paths: DaemonPaths): DaemonMetadataState }; } -export async function recoverDaemonLockHolder(paths: DaemonPaths): Promise { - const state = getDaemonMetadataState(paths); - if (!state.hasLock || state.hasInfo) return false; - const lockInfo = readDaemonLockInfo(paths.lockPath); - if (!lockInfo) { - removeDaemonLock(paths.lockPath); - return true; - } - if (!isAgentDeviceDaemonProcess(lockInfo.pid, lockInfo.processStartTime)) { - removeDaemonLock(paths.lockPath); - return true; - } - return false; -} - -export async function stopDaemonProcessForTakeover( - info: DaemonInfo, -): Promise { - const termination = await stopDaemonProcess( - { pid: info.pid, startTime: info.processStartTime ?? null }, - { - mode: 'graceful', - termTimeoutMs: DAEMON_TAKEOVER_TERM_TIMEOUT_MS, - killTimeoutMs: DAEMON_TAKEOVER_KILL_TIMEOUT_MS, - }, - ); - requireDaemonExit(termination); - return termination; -} - -function requireDaemonExit(termination: DaemonTerminationResult): void { - if (termination.status !== 'retained') return; - throw new AppError('COMMAND_FAILED', 'Daemon exit could not be confirmed.', { - reason: 'daemon_exit_unconfirmed', - termination, - }); -} - export function isRemoteDaemon(info: DaemonInfo): boolean { return typeof info.baseUrl === 'string' && info.baseUrl.length > 0; } @@ -288,21 +98,10 @@ export function resolveDaemonStartupHint( process.env.AGENT_DEVICE_STATE_DIR, ), ): string { - const cleanupCommand = buildDaemonMetadataCleanupCommand(paths); - if (state.hasLock && !state.hasInfo) { - return `agent-device attempted to clean stale daemon metadata automatically, but ${paths.lockPath} still exists without ${paths.infoPath}. Retry with --debug; if this persists after confirming no agent-device daemon process is running, run: ${cleanupCommand}`; - } - if (state.hasLock && state.hasInfo) { - return `agent-device attempted to clean stale daemon metadata automatically, but ${paths.infoPath} and ${paths.lockPath} still remain. Retry with --debug; if this persists after confirming no agent-device daemon process is running, run: ${cleanupCommand}`; - } - if (state.hasInfo) { - return `agent-device did not observe reachable daemon metadata after retrying, and ${paths.infoPath} still remains. Stale metadata was cleaned automatically when safe; retry with --debug. If this persists after confirming no agent-device daemon process is running, run: ${cleanupCommand}`; - } - return `agent-device did not observe reachable daemon metadata after retrying. Stale metadata was cleaned automatically when safe; retry with --debug and check daemon diagnostics logs. If stale metadata returns after confirming no agent-device daemon process is running, run: ${cleanupCommand}`; -} - -function buildDaemonMetadataCleanupCommand(paths: Pick) { - return `rm -f ${shellQuote(paths.infoPath)} ${shellQuote(paths.lockPath)}`; + const artifacts = [state.hasInfo ? paths.infoPath : null, state.hasLock ? paths.lockPath : null] + .filter(Boolean) + .join(' and '); + return `Daemon startup did not establish a reachable owner. ${artifacts ? `State was retained at ${artifacts}. ` : ''}Retry with --debug and inspect daemon diagnostics. Before upgrading, stop all older clients and daemons with their original CLI and prevent them from returning to this state directory. Unverified lock state requires confirming every user stopped before manual recovery; deleting metadata alone is not a safe reset.`; } function readJsonFile(filePath: string): unknown | null { @@ -313,11 +112,3 @@ function readJsonFile(filePath: string): unknown | null { return null; } } - -function removeFileIfExists(filePath: string): void { - try { - if (fs.existsSync(filePath)) fs.unlinkSync(filePath); - } catch { - // Best-effort cleanup only. - } -} diff --git a/src/daemon-client/daemon-client-timeout.ts b/src/daemon-client/daemon-client-timeout.ts index d338f74d02..12c87c0d93 100644 --- a/src/daemon-client/daemon-client-timeout.ts +++ b/src/daemon-client/daemon-client-timeout.ts @@ -1,18 +1,13 @@ -import { AppError, normalizeError } from '@agent-device/kernel/errors'; +import { AppError } from '@agent-device/kernel/errors'; import { runCmdSync } from '@agent-device/host-kit/command'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; -import { isAgentDeviceDaemonProcess } from '../daemon-process.ts'; +import type { DaemonRetirementResult } from '../daemon-registration-owner.ts'; import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; import { resolveCommandTimeoutPolicy } from '@agent-device/command-registry/registry'; import type { DaemonPaths } from '../daemon-resolution.ts'; import type { PlatformSelector } from '@agent-device/kernel/device'; -import { - removeDaemonInfo, - removeDaemonLock, - stopDaemonProcessForTakeover, - type DaemonInfo, -} from './daemon-client-metadata.ts'; +import type { DaemonInfo } from './daemon-client-metadata.ts'; const IOS_RUNNER_XCODEBUILD_KILL_PATTERNS = [ 'xcodebuild .*AgentDeviceRunnerUITests/RunnerTests/testCommand', @@ -40,7 +35,7 @@ function isAffirmativelyApplePlatform(platform: PlatformSelector | undefined): b return platform !== undefined && AFFIRMATIVE_APPLE_PLATFORM_SELECTORS.has(platform); } -export function handleRequestTimeout( +export async function handleRequestTimeout( params: Readonly<{ info: DaemonInfo; statePaths: DaemonPaths; @@ -53,7 +48,7 @@ export function handleRequestTimeout( session?: string; action?: string; }>, -): AppError { +): Promise { const { info, statePaths, remote, timeoutMs, requestId, command, platform, session, action } = params; // Cleanup eligibility stays UNCONDITIONAL for every local (non-remote) @@ -69,9 +64,17 @@ export function handleRequestTimeout( // a wrong skip. const cleanup = remote ? { terminated: 0 } : cleanupTimedOutIosRunnerBuilds(); const resetDaemon = !remote && shouldResetDaemonAfterRequestTimeout(command); - const daemonReset = resetDaemon - ? resetDaemonAfterTimeout(info, statePaths) - : { forcedKill: false }; + let retirement: DaemonRetirementResult | undefined; + if (resetDaemon) { + const { stopAndRetireDaemon } = await import('../daemon-registration-owner.ts'); + retirement = await stopAndRetireDaemon({ + paths: statePaths, + observed: { pid: info.pid, startTime: info.processStartTime ?? null }, + mode: 'force', + }); + } + const preserved = + retirement?.status === 'retained' && retirement.termination?.status !== 'exited'; // The HINT, unlike cleanup, may only name Apple-runner involvement on // evidence this call site actually has: an explicitly declared Apple // platform selector, or the cleanup itself having terminated a matching @@ -89,9 +92,10 @@ export function handleRequestTimeout( command, timedOutRunnerPidsTerminated: cleanup.terminated, timedOutRunnerCleanupError: cleanup.error, - daemonPidReset: resetDaemon ? info.pid : undefined, - daemonPidForceKilled: resetDaemon ? daemonReset.forcedKill : undefined, - daemonPreservedAfterTimeout: !remote && !resetDaemon, + daemonPidReset: retirement?.status === 'retired' ? info.pid : undefined, + daemonPidForceKilled: resetDaemon ? daemonWasForceKilled(retirement) : undefined, + daemonRetirement: retirement, + daemonPreservedAfterTimeout: preserved || (!remote && !resetDaemon), daemonBaseUrl: info.baseUrl, }, }); @@ -99,17 +103,27 @@ export function handleRequestTimeout( timeoutMs, requestId, reason: 'daemon_transport_timeout', - hint: resolveRequestTimeoutHint({ - remote, - resetDaemon, - command, - appleCleanupEvidence, - session, - action, - }), + ...(retirement ? { retirement, stateDir: statePaths.baseDir } : {}), + hint: + retirement?.status === 'retained' + ? `The daemon could not be safely retired. State was retained at ${statePaths.baseDir}. ${retirement.error?.hint ?? 'Retry with --debug and inspect daemon diagnostics before retrying.'}` + : resolveRequestTimeoutHint({ + remote, + resetDaemon, + command, + appleCleanupEvidence, + session, + action, + }), }); } +function daemonWasForceKilled(retirement: DaemonRetirementResult | undefined): boolean { + if (!retirement || retirement.status === 'absent') return false; + const termination = retirement.termination; + return termination?.status === 'exited' && termination.mode === 'forced'; +} + // Whether a timed-out request tears down the local daemon is declared on the // command's descriptor (ADR 0008, `timeoutPolicy.onTimeout`): read-only // capture/polling commands preserve the daemon so sessions survive and evidence @@ -177,25 +191,3 @@ function cleanupTimedOutIosRunnerBuilds(): { terminated: number; error?: string }; } } - -function resetDaemonAfterTimeout(info: DaemonInfo, paths: DaemonPaths): { forcedKill: boolean } { - let forcedKill = false; - try { - if (isAgentDeviceDaemonProcess(info.pid, info.processStartTime)) { - process.kill(info.pid, 'SIGKILL'); - forcedKill = true; - } - } catch { - void stopDaemonProcessForTakeover(info).catch((error: unknown) => { - emitDiagnostic({ - level: 'warn', - phase: 'daemon_timeout_stop_failed', - data: { error: normalizeError(error) }, - }); - }); - } finally { - removeDaemonInfo(paths.infoPath); - removeDaemonLock(paths.lockPath); - } - return { forcedKill }; -} diff --git a/src/daemon-client/daemon-client-transport.ts b/src/daemon-client/daemon-client-transport.ts index d09d5af386..66d545b0a3 100644 --- a/src/daemon-client/daemon-client-transport.ts +++ b/src/daemon-client/daemon-client-transport.ts @@ -72,23 +72,34 @@ type RemoteDaemonHealthLink = Pick< export async function canConnect( info: DaemonInfo, preference: DaemonTransportPreference, + probeTimeoutMs?: number, ): Promise { + const deadline = Date.now() + (probeTimeoutMs ?? Number.POSITIVE_INFINITY); const transport = chooseTransport(info, preference); - if (await canConnectWithTransport(info, transport)) return true; - const fallback = chooseAutoFallbackTransport(info, preference, transport); - return fallback ? await canConnectWithTransport(info, fallback) : false; + const firstBudget = (deadline - Date.now()) / (fallback ? 2 : 1); + if (await canConnectWithTransport(info, transport, firstBudget)) return Date.now() < deadline; + return fallback + ? (await canConnectWithTransport(info, fallback, deadline - Date.now())) && + Date.now() < deadline + : false; } async function canConnectWithTransport( info: DaemonInfo, transport: ResolvedDaemonTransport, + timeoutMs: number, ): Promise { - return transport === 'http' ? await canConnectHttp(info) : await canConnectSocket(info.port); + return transport === 'http' + ? await canConnectHttp(info, timeoutMs) + : await canConnectSocket(info.port, timeoutMs); } -export function canConnectSocket(port: number | undefined): Promise { - if (!port) return Promise.resolve(false); +export function canConnectSocket( + port: number | undefined, + timeoutMs = LOCAL_DAEMON_HEALTHCHECK_TIMEOUT_MS, +): Promise { + if (!port || timeoutMs <= 0) return Promise.resolve(false); return new Promise((resolve) => { let settled = false; const socket = net.createConnection({ host: '127.0.0.1', port }, () => { @@ -100,7 +111,7 @@ export function canConnectSocket(port: number | undefined): Promise { socket.destroy(); resolve(reachable); }; - socket.setTimeout(LOCAL_DAEMON_HEALTHCHECK_TIMEOUT_MS); + socket.setTimeout(Math.min(LOCAL_DAEMON_HEALTHCHECK_TIMEOUT_MS, Math.ceil(timeoutMs))); socket.on('timeout', () => { finish(false); }); @@ -110,8 +121,8 @@ export function canConnectSocket(port: number | undefined): Promise { }); } -function canConnectHttp(info: DaemonInfo): Promise { - return readDaemonHttpHealth(info).then((health) => health.reachable); +function canConnectHttp(info: DaemonInfo, timeoutMs: number): Promise { + return readDaemonHttpHealth(info, timeoutMs).then((health) => health.reachable); } export async function readRemoteDaemonHealth( @@ -152,17 +163,27 @@ async function readDaemonHttpHealth( : null; if (!endpoint) return { reachable: false }; const url = new URL(endpoint); - const transport = await loadNodeHttpRequester(url.protocol); const timeoutMs = Math.min( info.baseUrl ? REMOTE_DAEMON_HEALTHCHECK_TIMEOUT_MS : LOCAL_DAEMON_HEALTHCHECK_TIMEOUT_MS, probeTimeoutMs ?? Number.POSITIVE_INFINITY, ); if (timeoutMs <= 0) return { reachable: false, timedOut: true }; + const deadline = performance.now() + timeoutMs; const signal = AbortSignal.timeout(Math.ceil(timeoutMs)); + const transport = await Promise.race([ + loadNodeHttpRequester(url.protocol), + new Promise((resolve) => { + signal.addEventListener('abort', () => resolve(null), { once: true }); + }), + ]); + if (!transport || healthProbeExpired(signal, deadline)) + return { reachable: false, timedOut: true }; return await new Promise((resolve) => { const headers = info.baseUrl ? buildDaemonHttpAuthHeaders(info.token) : {}; const unreachable = (): RemoteDaemonHealth => - signal.aborted ? { reachable: false, timedOut: true } : { reachable: false }; + healthProbeExpired(signal, deadline) + ? { reachable: false, timedOut: true } + : { reachable: false }; const req = transport.request( { protocol: url.protocol, @@ -182,11 +203,11 @@ async function readDaemonHttpHealth( }); res.on('end', () => { const statusCode = res.statusCode ?? 500; - resolve({ - reachable: statusCode < 500, - statusCode, - ...readHealthPayload(body), - }); + resolve( + healthProbeExpired(signal, deadline) + ? { reachable: false, timedOut: true } + : { reachable: statusCode < 500, statusCode, ...readHealthPayload(body) }, + ); }); res.on('error', () => resolve(unreachable())); res.on('aborted', () => resolve(unreachable())); @@ -203,6 +224,10 @@ async function readDaemonHttpHealth( }); } +function healthProbeExpired(signal: AbortSignal, deadline: number): boolean { + return signal.aborted || performance.now() >= deadline; +} + function readHealthPayload(body: string): Omit { try { const parsed = JSON.parse(body) as { upstream?: unknown }; @@ -237,6 +262,29 @@ export async function sendRequest( statePaths: DaemonPaths, timeoutMs: number | undefined, options: SendRequestOptions = {}, +): Promise { + try { + return await sendRequestWithFallback(info, req, preference, statePaths, timeoutMs, options); + } catch (error) { + if (!(error instanceof AppError) || error.details?.reason !== 'daemon_transport_timeout') + throw error; + const expiredBudget = error.details.timeoutMs; + if (typeof expiredBudget !== 'number') throw error; + throw await handleRequestTimeout({ + info, + statePaths, + ...timeoutRequestContext(req, isRemoteDaemon(info), expiredBudget), + }); + } +} + +async function sendRequestWithFallback( + info: DaemonInfo, + req: DaemonRequest, + preference: DaemonTransportPreference, + statePaths: DaemonPaths, + timeoutMs: number | undefined, + options: SendRequestOptions = {}, ): Promise { const transport = chooseTransport(info, preference); const deadline = typeof timeoutMs === 'number' ? performance.now() + timeoutMs : undefined; @@ -271,30 +319,14 @@ async function retryAfterRemoteInstanceMismatch( options: SendRequestOptions, ): Promise { invalidateRemoteDaemonHealth(info); - const probeTimeoutMs = remainingRemoteRequestTimeoutMs( - info, + const probeTimeoutMs = remainingRemoteRequestTimeoutMs(req, timeoutMs, deadline); + const health = await readRemoteDaemonHealth(info, probeTimeoutMs); + const remainingMs = remainingRemoteRequestTimeoutMs( req, - statePaths, timeoutMs, deadline, + health.timedOut ? probeTimeoutMs : undefined, ); - const health = await readRemoteDaemonHealth(info, probeTimeoutMs); - // The probe's timer starts from the event loop's cached clock, so it can expire while - // performance.now() is still short of the deadline: a probe the RPC deadline capped that ran out - // of time is the RPC timing out. - if ( - health.timedOut && - timeoutMs !== undefined && - probeTimeoutMs !== undefined && - probeTimeoutMs <= REMOTE_DAEMON_HEALTHCHECK_TIMEOUT_MS - ) { - throw handleRequestTimeout({ - info, - statePaths, - ...timeoutRequestContext(req, true, timeoutMs), - }); - } - const remainingMs = remainingRemoteRequestTimeoutMs(info, req, statePaths, timeoutMs, deadline); if (!health.reachable) { throw new AppError('COMMAND_FAILED', 'Remote daemon is unavailable', { daemonBaseUrl: info.baseUrl, @@ -312,20 +344,20 @@ async function retryAfterRemoteInstanceMismatch( } function remainingRemoteRequestTimeoutMs( - info: DaemonInfo, req: DaemonRequest, - statePaths: DaemonPaths, timeoutMs: number | undefined, deadline: number | undefined, + timedOutProbeMs?: number, ): number | undefined { if (deadline === undefined || timeoutMs === undefined) return undefined; const remainingMs = deadline - performance.now(); - if (remainingMs > 0) return remainingMs; - throw handleRequestTimeout({ - info, - statePaths, - ...timeoutRequestContext(req, true, timeoutMs), - }); + // A probe capped by the request can expire before the monotonic clock catches up to its timer. + if ( + remainingMs > 0 && + (timedOutProbeMs === undefined || timedOutProbeMs > REMOTE_DAEMON_HEALTHCHECK_TIMEOUT_MS) + ) + return remainingMs; + throw requestTimeoutError(timeoutMs, req.meta?.requestId); } function isRemoteInstanceMismatch(error: unknown): boolean { @@ -351,7 +383,7 @@ async function sendRequestWithTransport( ): Promise { return transport === 'http' ? await sendHttpRequest(info, req, statePaths, timeoutMs, options) - : await sendSocketRequest(info, req, statePaths, timeoutMs, options); + : await sendSocketRequest(info, req, timeoutMs, options); } function chooseTransport( @@ -446,7 +478,6 @@ function handleTransportError( async function sendSocketRequest( info: DaemonInfo, req: DaemonRequest, - statePaths: DaemonPaths, timeoutMs: number | undefined, options: SendRequestOptions, ): Promise { @@ -462,15 +493,10 @@ async function sendSocketRequest( const timeoutHandle = typeof timeoutMs === 'number' ? setTimeout(() => { + if (settled) return; settled = true; + reject(requestTimeoutError(timeoutMs, req.meta?.requestId)); socket.destroy(); - reject( - handleRequestTimeout({ - info, - statePaths, - ...timeoutRequestContext(req, false, timeoutMs), - }), - ); }, timeoutMs) : undefined; @@ -504,6 +530,14 @@ async function sendSocketRequest( }); } +function requestTimeoutError(timeoutMs: number, requestId: string | undefined): AppError { + return new AppError('COMMAND_FAILED', 'Daemon request timed out', { + reason: 'daemon_transport_timeout', + timeoutMs, + requestId, + }); +} + // The fields a timed-out request is described by, read once so a socket and an HTTP timeout cannot // describe the same request differently. type TimeoutRequestFields = Omit[0], 'info' | 'statePaths'>; @@ -636,14 +670,8 @@ async function sendHttpRequest( const timeoutHandle = typeof timeoutMs === 'number' ? setTimeout(() => { + reject(requestTimeoutError(timeoutMs, req.meta?.requestId)); request.destroy(); - reject( - handleRequestTimeout({ - info, - statePaths, - ...timeoutRequestContext(req, remote, timeoutMs), - }), - ); }, timeoutMs) : undefined; diff --git a/src/daemon-client/daemon-client.ts b/src/daemon-client/daemon-client.ts index 7d777254fb..4d5e97e561 100644 --- a/src/daemon-client/daemon-client.ts +++ b/src/daemon-client/daemon-client.ts @@ -17,17 +17,7 @@ import { prepareRemoteRequestArtifacts, type PreparedRemoteRequest, } from '../remote/daemon-artifacts.ts'; -import { - attachActiveSessionAddressHint, - attachRepairSessionAddressHint, - cleanupDaemonAfterRequest, - ensureDaemon, - isActiveReplaySessionResponse, - isHeldRepairDivergence, - resolveClientSettings, - type DaemonClientSettings, - type EnsuredDaemon, -} from './daemon-client-lifecycle.ts'; +import type { DaemonClientSettings, EnsuredDaemon } from './daemon-client-lifecycle.ts'; import { sendRequest } from './daemon-client-transport.ts'; import { isRemoteDaemon, type DaemonInfo } from './daemon-client-metadata.ts'; import { leaseScopeFromRequest } from '@agent-device/contracts/lease-scope'; @@ -42,6 +32,8 @@ export async function sendToDaemon( req: Omit, options: DaemonTransportOptions = {}, ): Promise { + const { resolveClientSettings, ensureDaemon, attachSessionAddressHints } = + await import('./daemon-client-lifecycle.ts'); const requestId = req.meta?.requestId ?? createRequestId(); const debug = Boolean(req.meta?.debug || req.flags?.verbose); // A few internal callers build DaemonRequest directly instead of using the @@ -107,11 +99,7 @@ export async function sendToDaemon( ), { requestId, command: req.command }, ); - return withActiveSessionAddressHint( - withRepairSessionAddressHintIfOwned(response, settings), - requestWithoutAuthFlag, - settings, - ); + return attachSessionAddressHints(response, requestWithoutAuthFlag, settings); }, ); } @@ -235,6 +223,7 @@ async function performDaemonRequestWithCleanup( requestFailed = true; requestError = error; } + const { cleanupDaemonAfterRequest } = await import('./daemon-client-lifecycle.ts'); const finalResponse = await cleanupDaemonAfterRequest(req, daemon, settings, response); if (requestFailed) throw requestError; if (!finalResponse) { @@ -246,48 +235,6 @@ async function performDaemonRequestWithCleanup( return finalResponse; } -/** - * ADR 0012 decision 6 (Fix 1): the owned ephemeral state dir this daemon was - * started at is otherwise unaddressable by a later invocation — hint it here, - * only when the daemon is actually being kept alive for it - * (`settings.ownedStateDir` means `daemon.startedByClient` is also true). - */ -function withRepairSessionAddressHintIfOwned( - response: DaemonResponse, - settings: DaemonClientSettings, -): DaemonResponse { - if (response.ok || !settings.ownedStateDir || !isHeldRepairDivergence(response)) { - return response; - } - return attachRepairSessionAddressHint(response, settings.paths.baseDir); -} - -/** - * ADR 0016 counterpart to `withRepairSessionAddressHintIfOwned` — but unlike - * that one, NOT gated on `settings.ownedStateDir`. An owned ephemeral state - * dir is unaddressable by a later invocation either way, so it's included - * when owned; an explicit `--state-dir`/`AGENT_DEVICE_STATE_DIR` caller - * already knows their own dir, so it's omitted then. But the session's own - * name is cwd-qualified and, per #1394, `session list` cannot rediscover it - * either — so `--session` is still worth hinting even at an explicit state - * dir, which is why this runs for every active-session response regardless - * of `ownedStateDir` (`attachActiveSessionAddressHint` itself decides what, - * if anything, is worth attaching). - */ -function withActiveSessionAddressHint( - response: DaemonResponse, - req: Omit, - settings: DaemonClientSettings, -): DaemonResponse { - if (!response.ok || !isActiveReplaySessionResponse(req, response)) { - return response; - } - return attachActiveSessionAddressHint( - response, - settings.ownedStateDir ? settings.paths.baseDir : undefined, - ); -} - function writeInstallInProgressNotice(command: string | undefined): void { if (!isInstallLikeCommand(command) || process.stderr.isTTY !== true || process.env.CI) return; process.stderr.write( diff --git a/src/daemon-process.ts b/src/daemon-process.ts index fee88d3622..28869a01b5 100644 --- a/src/daemon-process.ts +++ b/src/daemon-process.ts @@ -1,5 +1,6 @@ import { isProcessAlive, + isProcessPid, readHostProcessIdentityObservations, readProcessCommand, readProcessStartTime, @@ -72,6 +73,7 @@ export async function waitForDaemonExit( identity: DaemonProcessIdentity, options: { timeoutMs: number; pollMs?: number }, ): Promise { + if (!isProcessPid(identity.pid)) return { exited: false, elapsedMs: 0 }; const startedAt = Date.now(); const deadline = startedAt + options.timeoutMs; const pollMs = options.pollMs ?? DAEMON_EXIT_POLL_MS; @@ -114,6 +116,7 @@ export async function stopDaemonProcess( killTimeoutMs: number; }, ): Promise { + if (!isProcessPid(observed.pid)) return { status: 'retained', reason: 'identity-unverified' }; if (!observed.startTime?.trim()) { if (!isProcessAlive(observed.pid)) return { status: 'not-running' }; return { status: 'retained', reason: 'missing-start-time' }; diff --git a/src/daemon-registration-owner.ts b/src/daemon-registration-owner.ts index 0092f087af..d2afde67bb 100644 --- a/src/daemon-registration-owner.ts +++ b/src/daemon-registration-owner.ts @@ -1,11 +1,18 @@ import fs from 'node:fs'; -import { normalizeError, type NormalizedError } from '@agent-device/kernel/errors'; +import os from 'node:os'; +import path from 'node:path'; +import { runCmdDetachedMonitored, type ExecDetachedExit } from '@agent-device/host-kit/command'; +import { AppError, normalizeError, type NormalizedError } from '@agent-device/kernel/errors'; import { stopDaemonProcess, waitForDaemonExit, type DaemonTerminationResult, } from './daemon-process.ts'; -import { readCurrentOwnerIdentity, type OwnerIdentity } from '@agent-device/host-kit/process'; +import { + readCurrentOwnerIdentity, + readProcessStartTime, + type OwnerIdentity, +} from '@agent-device/host-kit/process'; import { publishFileSync, tryAcquireProcessLock, @@ -15,7 +22,12 @@ import { } from '@agent-device/host-kit/file'; import { emitDiagnostic, withDiagnosticsScope } from '@agent-device/host-kit/diagnostics'; import type { DaemonCodeOrigin } from '@agent-device/host-kit/code-signature'; -import type { DaemonPaths } from './daemon-resolution.ts'; +import { + resolveDaemonPaths, + type DaemonPaths, + type DaemonServerMode, +} from './daemon-resolution.ts'; +import { findUnrecoveredRepairCommitFailure } from './session-repair-tombstone.ts'; import { readRegisteredDaemonOwnership, type RegisteredDaemonOwnership, @@ -161,6 +173,95 @@ function truncateDaemonLog(logPath: string): void { } } +declare const privateReplayState: unique symbol; +export type OwnedReplayStateDir = Readonly<{ + paths: Readonly; + [privateReplayState]: true; +}>; +export type DaemonStartupLaunch = Readonly<{ + pid: number; + startTime?: string; + exited: Promise; +}>; +type OwnedStartup = { launch: DaemonStartupLaunch; joined: boolean }; +type PrivateReplayState = { + paths: Readonly; + startups: OwnedStartup[]; + sealed: boolean; + retirement?: Promise; +}; +const privateReplayStates = new WeakMap(); + +/** Creates deletion authority only for a fresh private replay directory. */ +export function createOwnedReplayStateDir(): OwnedReplayStateDir { + const paths = Object.freeze( + resolveDaemonPaths(fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-daemon-'))), + ); + const owned = Object.freeze({ paths }) as OwnedReplayStateDir; + privateReplayStates.set(owned, { paths, startups: [], sealed: false }); + return owned; +} + +/** Launches and monitors the actual child before recording its private-directory authority. */ +export function launchDaemonProcess( + input: Readonly<{ + paths: DaemonPaths; + args: string[]; + serverMode: DaemonServerMode; + ownedStateDir?: OwnedReplayStateDir; + }>, +): DaemonStartupLaunch { + const owned = input.ownedStateDir && requirePrivateReplayState(input.ownedStateDir, input.paths); + if (owned?.sealed) + throw new AppError('COMMAND_FAILED', 'Replay daemon startup admission is closed.', { + reason: 'daemon_startup_admission_closed', + }); + fs.mkdirSync(input.paths.baseDir, { recursive: true }); + const logFd = fs.openSync(input.paths.logPath, 'a'); + try { + const monitored = runCmdDetachedMonitored(process.execPath, input.args, { + env: { + ...process.env, + AGENT_DEVICE_STATE_DIR: input.paths.baseDir, + AGENT_DEVICE_DAEMON_SERVER_MODE: input.serverMode, + }, + stdio: ['ignore', logFd, logFd], + }); + const startup: OwnedStartup = { launch: Object.freeze({ ...monitored }), joined: false }; + if (owned) { + owned.startups.push(startup); + void monitored.exited.then(() => { + startup.joined = true; + }); + } + startup.launch = Object.freeze({ + ...startup.launch, + startTime: readProcessStartTime(monitored.pid) ?? undefined, + }); + return startup.launch; + } finally { + fs.closeSync(logFd); + } +} + +function requirePrivateReplayState( + capability: OwnedReplayStateDir, + paths: DaemonPaths, +): PrivateReplayState { + const owned = privateReplayStates.get(capability); + if ( + !owned || + Object.entries(owned.paths).some(([key, value]) => paths[key as keyof DaemonPaths] !== value) + ) { + throw new AppError( + 'COMMAND_FAILED', + 'Private replay directory ownership could not be verified.', + { reason: 'daemon_private_state_unowned' }, + ); + } + return owned; +} + export type DaemonRetirementInput = Readonly<{ paths: DaemonPaths; observed: OwnerIdentity | null; @@ -169,16 +270,25 @@ export type DaemonRetirementInput = Readonly<{ lockTimeoutMs?: number; }>; +type RepairCommitFailure = NonNullable>; type ConfirmedDaemonTermination = Extract; export type DaemonRetirementResult = - | Readonly<{ status: 'retired'; termination: ConfirmedDaemonTermination; removedInfo: boolean }> + | Readonly<{ + status: 'retired'; + termination: ConfirmedDaemonTermination; + removedInfo: boolean; + removedStateDir?: boolean; + repairCommitFailure?: RepairCommitFailure; + }> | Readonly<{ status: 'absent'; removedInfo: false }> | Readonly<{ status: 'retained'; termination?: DaemonTerminationResult; removedInfo: boolean; + removedStateDir?: boolean; reason: | 'ownership-unproven' + | 'startup-unconfirmed' | 'exit-unconfirmed' | 'stop-failed' | 'lock-busy' @@ -186,19 +296,59 @@ export type DaemonRetirementResult = | 'metadata-unreadable' | 'retirement-unconfirmed'; error?: NormalizedError; + repairCommitFailure?: RepairCommitFailure; }>; /** Stops only the captured daemon lifetime, then retires its registration under the startup lock. */ export async function stopAndRetireDaemon( - input: DaemonRetirementInput & Readonly<{ mode: 'graceful' | 'force' }>, + input: DaemonRetirementInput & + Readonly<{ + mode: 'graceful' | 'force'; + ownedStateDir?: OwnedReplayStateDir; + startupJoinTimeoutMs?: number; + }>, ): Promise { - return await retireObservedDaemon(input, (identity) => - stopDaemonProcess(identity, { - mode: input.mode, - termTimeoutMs: input.termTimeoutMs ?? 3_000, - killTimeoutMs: input.killTimeoutMs ?? 1_000, - }), + let owned: PrivateReplayState | undefined; + try { + if (input.ownedStateDir) { + owned = requirePrivateReplayState(input.ownedStateDir, input.paths); + owned.sealed = true; + const launch = owned.startups.find( + ({ launch }) => + launch.pid === input.observed?.pid && launch.startTime === input.observed?.startTime, + )?.launch; + if (!launch?.startTime) + throw new AppError( + 'COMMAND_FAILED', + 'The observed daemon is not an owned startup lifetime.', + { reason: 'daemon_private_startup_unowned' }, + ); + if (owned.retirement) return await owned.retirement; + } + } catch (error) { + return { + status: 'retained', + reason: 'ownership-unproven', + removedInfo: false, + error: normalizeError(error), + }; + } + const retirement = retireObservedDaemon( + input, + (identity) => + stopDaemonProcess(identity, { + mode: input.mode, + termTimeoutMs: input.termTimeoutMs ?? 3_000, + killTimeoutMs: input.killTimeoutMs ?? 1_000, + }), + owned, + input.startupJoinTimeoutMs ?? 1_000, ); + if (owned) owned.retirement = retirement; + const result = await retirement; + if (owned && (result.status === 'absent' || !result.removedStateDir)) + owned.retirement = undefined; + return result; } /** Recovers a confirmed abandoned registration without signaling a live process. */ @@ -217,6 +367,8 @@ export async function recoverAbandonedDaemonRegistration( async function retireObservedDaemon( input: DaemonRetirementInput, terminate: (identity: OwnerIdentity) => Promise, + owned?: PrivateReplayState, + startupJoinTimeoutMs = 1_000, ): Promise { const paths = { ...input.paths }; const observed = input.observed && { ...input.observed }; @@ -241,12 +393,16 @@ async function retireObservedDaemon( }; } } - return await retireDaemonRegistration({ ...input, paths }, termination); + if (owned && !(await joinOwnedStartups(owned, startupJoinTimeoutMs))) { + return { status: 'retained', reason: 'startup-unconfirmed', termination, removedInfo: false }; + } + return await retireDaemonRegistration({ ...input, paths }, termination, owned); } async function retireDaemonRegistration( input: DaemonRetirementInput, termination: ConfirmedDaemonTermination | undefined, + owned?: PrivateReplayState, ): Promise { const paths = input.paths; let acquisition: ProcessLockAcquisition; @@ -268,7 +424,11 @@ async function retireDaemonRegistration( error: failure, }; } - let result: DaemonRetirementResult; + let result: DaemonRetirementResult = { + status: 'retained', + reason: 'retirement-unconfirmed', + removedInfo: false, + }; try { const removal = removeRegistrationUnderLock( paths.infoPath, @@ -276,12 +436,13 @@ async function retireDaemonRegistration( acquisition, ); result = retirementAfterRemoval(removal, termination); + result = retirePrivateStateIfEligible(owned, acquisition, result); } catch (error) { result = { status: 'retained', reason: 'retirement-unconfirmed', termination, - removedInfo: false, + removedInfo: result.removedInfo, error: normalizeError(error), }; } @@ -339,3 +500,42 @@ async function recordRegistrationWarning( emitDiagnostic({ level: 'warn', phase, data: { error: normalizeError(error) } }); }); } + +async function joinOwnedStartups(owned: PrivateReplayState, timeoutMs: number): Promise { + if (owned.startups.every((startup) => startup.joined)) return true; + let timer: ReturnType | undefined; + try { + return await Promise.race([ + Promise.all(owned.startups.map(({ launch }) => launch.exited)).then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), timeoutMs); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +function retirePrivateStateIfEligible( + owned: PrivateReplayState | undefined, + acquisition: ProcessLockAcquisition, + result: DaemonRetirementResult, +): DaemonRetirementResult { + if (!owned || result.status !== 'retired') return result; + try { + acquisition.assertHeld(); + const failure = findUnrecoveredRepairCommitFailure(owned.paths.sessionsDir); + if (failure) return { ...result, removedStateDir: false, repairCommitFailure: failure }; + acquisition.assertHeld(); + fs.rmSync(owned.paths.baseDir, { recursive: true, force: true }); + return { ...result, removedStateDir: true }; + } catch (error) { + return { + ...result, + status: 'retained', + reason: 'retirement-unconfirmed', + removedStateDir: false, + error: normalizeError(error), + }; + } +} diff --git a/src/daemon-registration.ts b/src/daemon-registration.ts index ad598afc3c..22f892971b 100644 --- a/src/daemon-registration.ts +++ b/src/daemon-registration.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import { ownerIdentityDiffers, + isProcessPid, ownerIdentityMatches, type OwnerIdentity, } from '@agent-device/host-kit/process'; @@ -53,7 +54,7 @@ function parseRegistration(parsed: { processStartTime?: unknown; }): ParsedRegistration { const pid = parsed.pid; - if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) { + if (!isProcessPid(pid)) { return { pid: null, startTime: null }; } return { pid, startTime: readableStartTime(parsed.processStartTime) }; diff --git a/src/daemon/__tests__/android-owner-seam.test.ts b/src/daemon/__tests__/android-owner-seam.test.ts index f522365dfa..29fbe29836 100644 --- a/src/daemon/__tests__/android-owner-seam.test.ts +++ b/src/daemon/__tests__/android-owner-seam.test.ts @@ -62,11 +62,13 @@ test('provider-owned Android sessions bypass local observation and recovery', as }, ], }; + const sessionStore = new SessionStore('/tmp/provider-owned-android'); + const ref = sessionStore.publish(session.name, session); await expect( resolveDirectTouchReferenceFrameSafely({ - session, + ref, flags: undefined, - sessionStore: new SessionStore('/tmp/provider-owned-android'), + sessionStore, contextFromFlags: () => ({}), captureSnapshotForSession: async () => session.snapshot!, observation, diff --git a/src/daemon/__tests__/app-log-session-resource.test.ts b/src/daemon/__tests__/app-log-session-resource.test.ts index 84a3add4f2..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__/daemon-stop.test.ts b/src/daemon/__tests__/daemon-stop.test.ts index 8a294910b6..13ac6851d4 100644 --- a/src/daemon/__tests__/daemon-stop.test.ts +++ b/src/daemon/__tests__/daemon-stop.test.ts @@ -2,15 +2,9 @@ import fs from 'node:fs'; import { afterEach, expect, test, vi } from 'vitest'; import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; -const mocks = vi.hoisted(() => ({ - stopDaemonProcess: vi.fn(), - sleep: vi.fn(async () => undefined), -})); - -vi.mock('../../daemon-process.ts', () => ({ stopDaemonProcess: mocks.stopDaemonProcess })); -vi.mock('@agent-device/host-kit/retry', async (importOriginal) => ({ - ...(await importOriginal()), - sleep: mocks.sleep, +const mocks = vi.hoisted(() => ({ stopAndRetireDaemon: vi.fn() })); +vi.mock('../../daemon-registration-owner.ts', () => ({ + stopAndRetireDaemon: mocks.stopAndRetireDaemon, })); import { resolveDaemonPaths } from '../../daemon-resolution.ts'; @@ -45,40 +39,34 @@ test('reports not-running when daemon metadata is absent', async () => { test('retained identity verification is reported as failure without known cleanup', async () => { const paths = createDaemonPaths(); - mocks.stopDaemonProcess.mockResolvedValue({ status: 'retained', reason: 'identity-unverified' }); + mocks.stopAndRetireDaemon.mockResolvedValue(retainedExit('identity-unverified')); await expect(stopDaemon({ paths })).rejects.toMatchObject({ code: 'COMMAND_FAILED', details: { reason: 'daemon_exit_unconfirmed', terminationReason: 'identity-unverified' }, }); - expect(mocks.stopDaemonProcess).toHaveBeenCalledWith( - { pid: 123, startTime: 'start-time' }, - { - mode: 'graceful', - termTimeoutMs: 10_000, - killTimeoutMs: 2_000, - }, - ); + expect(mocks.stopAndRetireDaemon).toHaveBeenCalledWith({ + paths, + observed: { pid: 123, startTime: 'start-time' }, + mode: 'graceful', + termTimeoutMs: 10_000, + killTimeoutMs: 2_000, + }); }); test('missing start-time identity is passed to the owning termination operation', async () => { const paths = createDaemonPaths(); fs.writeFileSync(paths.infoPath, JSON.stringify({ pid: 123, processStartTime: ' ' })); - mocks.stopDaemonProcess.mockResolvedValue({ status: 'retained', reason: 'missing-start-time' }); + mocks.stopAndRetireDaemon.mockResolvedValue(retainedExit('missing-start-time')); await expect(stopDaemon({ paths })).rejects.toMatchObject({ details: { terminationReason: 'missing-start-time' }, }); - expect(mocks.stopDaemonProcess).toHaveBeenCalledWith( - { pid: 123, startTime: null }, - expect.anything(), + expect(mocks.stopAndRetireDaemon).toHaveBeenCalledWith( + expect.objectContaining({ paths, observed: { pid: 123, startTime: null } }), ); }); test('a previously exited verified lifetime is reported as not-running', async () => { - mocks.stopDaemonProcess.mockResolvedValue({ - status: 'exited', - mode: 'already-exited', - identity: { pid: 123, startTime: 'start-time' }, - }); + mocks.stopAndRetireDaemon.mockResolvedValue(retired('already-exited')); expect(await stopDaemon({ paths: createDaemonPaths() })).toMatchObject({ stopped: false, mode: 'not-running', @@ -86,8 +74,15 @@ test('a previously exited verified lifetime is reported as not-running', async ( }); test('an already released pid without start time remains not-running without cleanup proof', async () => { - mocks.stopDaemonProcess.mockResolvedValue({ status: 'not-running' }); - expect(await stopDaemon({ paths: createDaemonPaths() })).toMatchObject({ + const paths = createDaemonPaths(); + fs.writeFileSync(paths.infoPath, JSON.stringify({ pid: 123 })); + mocks.stopAndRetireDaemon.mockResolvedValue({ + status: 'retained', + reason: 'exit-unconfirmed', + removedInfo: false, + termination: { status: 'not-running' }, + }); + expect(await stopDaemon({ paths })).toMatchObject({ stopped: false, mode: 'not-running', }); @@ -95,32 +90,24 @@ test('an already released pid without start time remains not-running without cle test('confirmed TERM exit preserves graceful report behavior and configured budgets', async () => { const paths = createDaemonPaths(); - mocks.stopDaemonProcess.mockImplementation(async () => { - fs.rmSync(paths.infoPath, { force: true }); - return { status: 'exited', mode: 'graceful', identity: { pid: 123, startTime: 'start-time' } }; - }); + mocks.stopAndRetireDaemon.mockResolvedValue(retired('graceful')); expect(await stopDaemon({ paths, graceTimeoutMs: 11, killTimeoutMs: 7 })).toMatchObject({ stopped: true, mode: 'graceful', cleanupConfidence: 'known', providerReleases: { pending: [] }, }); - expect(mocks.stopDaemonProcess).toHaveBeenCalledWith( - { pid: 123, startTime: 'start-time' }, - { - mode: 'graceful', - termTimeoutMs: 11, - killTimeoutMs: 7, - }, - ); + expect(mocks.stopAndRetireDaemon).toHaveBeenCalledWith({ + paths, + observed: { pid: 123, startTime: 'start-time' }, + mode: 'graceful', + termTimeoutMs: 11, + killTimeoutMs: 7, + }); }); test('confirmed KILL exit preserves unknown provider cleanup', async () => { - mocks.stopDaemonProcess.mockResolvedValue({ - status: 'exited', - mode: 'forced', - identity: { pid: 123, startTime: 'start-time' }, - }); + mocks.stopAndRetireDaemon.mockResolvedValue(retired('forced')); expect(await stopDaemon({ paths: createDaemonPaths() })).toMatchObject({ stopped: true, mode: 'forced', @@ -133,10 +120,58 @@ test('confirmed KILL exit preserves unknown provider cleanup', async () => { test.each(['signal-failed', 'exit-timeout'])( '%s cannot become a successful stop', async (reason) => { - mocks.stopDaemonProcess.mockResolvedValue({ status: 'retained', reason }); + mocks.stopAndRetireDaemon.mockResolvedValue(retainedExit(reason)); await expect(stopDaemon({ paths: createDaemonPaths() })).rejects.toMatchObject({ code: 'COMMAND_FAILED', details: { reason: 'daemon_exit_unconfirmed', terminationReason: reason }, }); }, ); + +function retainedExit(reason: string) { + return { + status: 'retained', + reason: 'exit-unconfirmed', + removedInfo: false, + termination: { status: 'retained', reason }, + }; +} + +function retired(mode: string) { + return { + status: 'retired', + removedInfo: true, + termination: { status: 'exited', mode, identity: { pid: 123, startTime: 'start-time' } }, + }; +} + +test.each(['registration-replaced', 'retirement-unconfirmed'])( + '%s after confirmed exit cannot report a completed retirement', + async (reason) => { + const paths = createDaemonPaths(); + mocks.stopAndRetireDaemon.mockResolvedValue({ + ...retired('forced'), + status: 'retained', + reason, + removedInfo: false, + error: { + code: 'UNKNOWN', + message: 'retained', + hint: 'Inspect retained state.', + diagnosticId: 'diag-retire', + logPath: '/retained/daemon.log', + }, + }); + await expect(stopDaemon({ paths })).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { + reason: 'daemon_retirement_unconfirmed', + retirement: { status: 'retained', reason }, + hint: 'Inspect retained state.', + diagnosticId: 'diag-retire', + logPath: '/retained/daemon.log', + }, + }); + expect(fs.existsSync(paths.infoPath)).toBe(true); + }, +); diff --git a/src/daemon/__tests__/filesystem-boundary-faults.test.ts b/src/daemon/__tests__/filesystem-boundary-faults.test.ts index da432598cc..bf19ec91b4 100644 --- a/src/daemon/__tests__/filesystem-boundary-faults.test.ts +++ b/src/daemon/__tests__/filesystem-boundary-faults.test.ts @@ -1,3 +1,4 @@ +import { storeSessionForTest } from '../../__tests__/test-utils/store-factory.ts'; import assert from 'node:assert/strict'; import crypto from 'node:crypto'; import path from 'node:path'; @@ -158,7 +159,7 @@ function createSessionStoreFixture(root: string): FilesystemBoundaryFixture { return { targetPath, - run: async () => store.finalizeRepairTeardown(session), + run: async () => store.finalizeRepairTeardown(storeSessionForTest(store, session)), expected: 'return', verifyReturn: (_value, errno) => { const tombstone = store.readRepairTombstone(session.name); diff --git a/src/daemon/__tests__/generic-settle.test.ts b/src/daemon/__tests__/generic-settle.test.ts index fa0eb55985..5ae2c10d04 100644 --- a/src/daemon/__tests__/generic-settle.test.ts +++ b/src/daemon/__tests__/generic-settle.test.ts @@ -209,7 +209,8 @@ beforeEach(() => { mockCaptureSnapshotForSession.mockReset(); mockCaptureSnapshotForSession.mockImplementation( (...args: Parameters) => { - const [session, flags, sessionStore, _contextFromFlags, options] = args; + const [ref, flags, sessionStore, _contextFromFlags, options] = args; + const session = sessionStore.requireCurrent(ref); return emulateCaptureSnapshotForSession(session, flags, sessionStore, options); }, ); diff --git a/src/daemon/__tests__/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__/replay-repair/session-replay-repair-acceptance.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair-acceptance.test.ts index 76b16120ff..1f9e7d44f2 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-acceptance.test.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-acceptance.test.ts @@ -1,3 +1,4 @@ +import { storeSessionForTest } from '../../../__tests__/test-utils/store-factory.ts'; /** * ADR 0012 decision 6 acceptance test: a healed sibling `.ad` produced by the * repair loop must replay end-to-end in a FRESH session, with every selector @@ -140,7 +141,7 @@ test('a healed script survives repair + fresh-session replay: self-contained ope // repair-armed write on the same explicit finalize signal `close // --save-script` sets). --- markRepairTransactionComplete(session); - sessionStore.writeSessionLog(session); + sessionStore.writeSessionLog(storeSessionForTest(sessionStore, session)); const healedPath = path.join(root, 'flow.healed.ad'); expect(fs.existsSync(healedPath)).toBe(true); const healedScript = fs.readFileSync(healedPath, 'utf8'); diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair-empty-tail.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair-empty-tail.test.ts index ae1788f148..04dd93bed5 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-empty-tail.test.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-empty-tail.test.ts @@ -1,3 +1,4 @@ +import { storeSessionForTest } from '../../../__tests__/test-utils/store-factory.ts'; /** * ADR 0012 decision 6, R2/R3, extended per #1262: behaviors introduced * alongside the `resume.from` / `repairHint` agreement fix @@ -188,7 +189,7 @@ test('a record-and-heal divergence on the LAST step resumes with an empty tail a // --- Commit: the transaction is COMPLETE, so the healed script actually // publishes — the corrective press survives, "click" (never recorded) does // not. Proves the empty-tail resume did not lead to a discarded repair. --- - const writeResult = sessionStore.writeSessionLog(session); + const writeResult = sessionStore.writeSessionLog(storeSessionForTest(sessionStore, session)); expect(writeResult.written).toBe(true); const healedPath = path.join(root, 'flow.healed.ad'); expect(fs.existsSync(healedPath)).toBe(true); @@ -312,7 +313,7 @@ test('a manual divergence (unannotated action-failure) on the LAST step resumes // since a `manual` divergence never dispatched it) does not. Proves the // empty-tail resume did not lead to a discarded repair (the #1260 // discard-at-close trap, now also closed for `manual`). --- - const writeResult = sessionStore.writeSessionLog(session); + const writeResult = sessionStore.writeSessionLog(storeSessionForTest(sessionStore, session)); expect(writeResult.written).toBe(true); const healedPath = path.join(root, 'flow.healed.ad'); expect(fs.existsSync(healedPath)).toBe(true); @@ -436,7 +437,7 @@ test('a caution (identity-mismatch) divergence on the LAST step resumes with an // --- Commit: COMPLETE, so the healed script publishes the corrective // press; the pre-action "click" (never dispatched) does not appear. --- - const writeResult = sessionStore.writeSessionLog(session); + const writeResult = sessionStore.writeSessionLog(storeSessionForTest(sessionStore, session)); expect(writeResult.written).toBe(true); const healedPath = path.join(root, 'flow.healed.ad'); expect(fs.existsSync(healedPath)).toBe(true); diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-close-ordering.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-close-ordering.test.ts index 3fdede4587..c4065cff2d 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-close-ordering.test.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-close-ordering.test.ts @@ -1,3 +1,4 @@ +import { storeSessionForTest } from '../../../__tests__/test-utils/store-factory.ts'; /** * ADR 0012 decision 6 repair-transaction close-ordering guarantees (BLOCKER 2/3 sequencing): the * platform close must run and succeed BEFORE the healed `.ad` commits (never claim a successful @@ -244,7 +245,7 @@ test('BLOCKER 3: a competing second writer never overwrites a COMPLETE artifact // Writer 1 commits a complete artifact at the default healed path. const first = makeCompleteRepairSession(sessionStore, `${sessionName}-1`, root); - const r1 = sessionStore.writeSessionLog(first); + const r1 = sessionStore.writeSessionLog(storeSessionForTest(sessionStore, first)); expect(r1.written).toBe(true); const committed = fs.readFileSync(healedPath, 'utf8'); expect(committed).toContain(HEAL_COMPLETE_SENTINEL); @@ -261,7 +262,7 @@ test('BLOCKER 3: a competing second writer never overwrites a COMPLETE artifact result: { selectorChain: ['id="different"'] }, targetEvidence: freshEvidence('different', 'Different'), }; - const r2 = sessionStore.writeSessionLog(second); + const r2 = sessionStore.writeSessionLog(storeSessionForTest(sessionStore, second)); expect(r2.written).toBe(false); expect(r2.written === false && r2.error?.message).toMatch(/already exists/); // The first writer's complete artifact is byte-for-byte intact. diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction.test.ts index 9cc56d45bd..3136669a5f 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction.test.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction.test.ts @@ -1,3 +1,4 @@ +import { storeSessionForTest } from '../../../__tests__/test-utils/store-factory.ts'; /** * ADR 0012 decision 6 "repair transaction" lifecycle fixes (Q1/Q2a/Q2b/Q2c): * proves the WHOLE chain end to end, at the layer these fixes actually live — @@ -304,7 +305,7 @@ test('C5a: an incomplete repair reaped by idle-reap leaves a tombstone (no heale // Idle-reap tears the still-incomplete repair session down: the writer commits // nothing (not complete) and a tombstone is left behind (the exact teardown // step daemon-runtime.ts's teardownDaemonSession runs). - sessionStore.finalizeRepairTeardown(session); + sessionStore.finalizeRepairTeardown(storeSessionForTest(sessionStore, session)); sessionStore.delete(sessionName); expect(fs.existsSync(path.join(root, 'flow.healed.ad'))).toBe(false); @@ -353,7 +354,7 @@ test('C5a/BLOCKER 3: teardown of a COMPLETE repair auto-commits a self-contained // Teardown (e.g. the client tearing down the ephemeral daemon after a clean // repair) auto-commits the completed transaction and leaves no tombstone. - sessionStore.finalizeRepairTeardown(session); + sessionStore.finalizeRepairTeardown(storeSessionForTest(sessionStore, session)); expect(fs.existsSync(path.join(root, 'flow.healed.ad'))).toBe(true); const healedScript = fs.readFileSync(path.join(root, 'flow.healed.ad'), 'utf8'); expect(healedScript).toContain(HEAL_COMPLETE_SENTINEL); @@ -398,7 +399,7 @@ test('BLOCKER 1: a --from continuation on a reaped session returns SESSION_NOT_F const digest = leg1Divergence.resume.planDigest; // Idle-reap tears the incomplete repair down, leaving a tombstone. - sessionStore.finalizeRepairTeardown(sessionStore.get(sessionName)!); + sessionStore.finalizeRepairTeardown(sessionStore.lookup(sessionName)!); sessionStore.delete(sessionName); expect(sessionStore.readRepairTombstone(sessionName)).toBeDefined(); diff --git a/src/daemon/__tests__/request-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__/request-router-idle-expired.test.ts b/src/daemon/__tests__/request-router-idle-expired.test.ts index 94c4f0e0ed..b69d2f9fa6 100644 --- a/src/daemon/__tests__/request-router-idle-expired.test.ts +++ b/src/daemon/__tests__/request-router-idle-expired.test.ts @@ -95,8 +95,7 @@ test('an expired marker that has aged out stops explaining the absence', async ( test('an abandoned repair transaction outranks the idle-expiry marker', async () => { const { sessionStore, handler } = makeHandler('agent-device-router-idle-vs-repair-'); - writeIdleMarker(sessionStore, 'repair-x'); - sessionStore.writeRepairTombstone({ + const ref = sessionStore.publish('repair-x', { name: 'repair-x', device: { platform: 'apple', id: 'sim-1', name: 'iPhone', kind: 'simulator', booted: true }, createdAt: Date.now(), @@ -110,6 +109,10 @@ test('an abandoned repair transaction outranks the idle-expiry marker', async () }, }); + sessionStore.writeRepairTombstone(ref); + sessionStore.retire(ref); + writeIdleMarker(sessionStore, 'repair-x'); + const response = await handler(closeRequest('repair-x')); expect(response.ok).toBe(false); diff --git a/src/daemon/__tests__/request-router-repair-expired.test.ts b/src/daemon/__tests__/request-router-repair-expired.test.ts index 0e9947be33..f34f4cee8f 100644 --- a/src/daemon/__tests__/request-router-repair-expired.test.ts +++ b/src/daemon/__tests__/request-router-repair-expired.test.ts @@ -62,7 +62,9 @@ test('a command that finds no session but hits a live repair tombstone gets REPA const { sessionStore, handler } = makeHandler('agent-device-router-repair-expired-'); // The repair session was reaped (idle-reap) leaving a tombstone; the store // has no live session by that name. - sessionStore.writeRepairTombstone(tombstonedSession('repair-x')); + const ref = sessionStore.publish('repair-x', tombstonedSession('repair-x')); + sessionStore.writeRepairTombstone(ref); + sessionStore.retire(ref); const response = await handler(closeRequest('repair-x')); @@ -89,10 +91,12 @@ test('without a tombstone, a missing session still returns a plain SESSION_NOT_F // never completed at all. test('a command hitting a commit-failure tombstone gets REPAIR_COMMIT_FAILED with the real cause, not a generic REPAIR_SESSION_EXPIRED', async () => { const { sessionStore, handler } = makeHandler('agent-device-router-commit-failed-'); - sessionStore.writeRepairTombstone(tombstonedSession('repair-commit-fail'), undefined, { + const ref = sessionStore.publish('repair-commit-fail', tombstonedSession('repair-commit-fail')); + sessionStore.writeRepairTombstone(ref, undefined, { code: 'COMMAND_FAILED', message: 'A prior healed script already exists at /flows/login.healed.ad; ...', }); + sessionStore.retire(ref); const response = await handler(closeRequest('repair-commit-fail')); @@ -108,7 +112,9 @@ test('a command hitting a commit-failure tombstone gets REPAIR_COMMIT_FAILED wit test('an expired tombstone does not shadow a missing session', async () => { const { sessionStore, handler } = makeHandler('agent-device-router-expired-tombstone-'); // TTL 0 => already stale. - sessionStore.writeRepairTombstone(tombstonedSession('repair-y'), 0); + const ref = sessionStore.publish('repair-y', tombstonedSession('repair-y')); + sessionStore.writeRepairTombstone(ref, 0); + sessionStore.retire(ref); const response = await handler(closeRequest('repair-y')); @@ -144,7 +150,9 @@ test('a replay --from continuation on a reaped repair session gets REPAIR_SESSIO }).planDigest; // The repair session was reaped, leaving a tombstone; no live session exists. - sessionStore.writeRepairTombstone(tombstonedSession('repair-from')); + const ref = sessionStore.publish('repair-from', tombstonedSession('repair-from')); + sessionStore.writeRepairTombstone(ref); + sessionStore.retire(ref); const response = await handler({ token: 'test-token', diff --git a/src/daemon/__tests__/request-save-script-transports.test.ts b/src/daemon/__tests__/request-save-script-transports.test.ts index 730a37d25a..6516da75b3 100644 --- a/src/daemon/__tests__/request-save-script-transports.test.ts +++ b/src/daemon/__tests__/request-save-script-transports.test.ts @@ -1,3 +1,4 @@ +import { storeSessionForTest } from '../../__tests__/test-utils/store-factory.ts'; import { isSessionRecording } from '../session-script-publication-capability.ts'; import { createTestDeviceInventoryGateways } from '../../__tests__/test-utils/device-inventory-gateways.ts'; /** @@ -204,7 +205,9 @@ for (const [transport, send] of TRANSPORTS) { expect(isSessionRecording(session)).toBe(false); expect(session.scriptPublication).toBe(undefined); // No artifact: the write a later close/teardown would attempt publishes nothing. - expect(sessionStore.writeSessionLog(session)).toEqual({ written: false }); + expect(sessionStore.writeSessionLog(storeSessionForTest(sessionStore, session))).toEqual({ + written: false, + }); expect(listAdArtifacts(root)).toEqual([]); expect(fs.existsSync(path.join(root, 'forged.ad'))).toBe(false); @@ -295,7 +298,7 @@ test('an owner-armed session still records its target and publishes its script', expect(isSessionRecording(session)).toBe(true); expect(scriptTargetPath(session.scriptPublication ?? NO_SCRIPT_PUBLICATION)).toBe(target); - const result = sessionStore.writeSessionLog(session); + const result = sessionStore.writeSessionLog(storeSessionForTest(sessionStore, session)); expect(result).toEqual({ written: true, path: target, actionCount: 1 }); expect(fs.readFileSync(target, 'utf8')).toMatch(/^open /m); }); diff --git a/src/daemon/__tests__/runtime-session.test.ts b/src/daemon/__tests__/runtime-session.test.ts index 60426909d6..8c70b3e82f 100644 --- a/src/daemon/__tests__/runtime-session.test.ts +++ b/src/daemon/__tests__/runtime-session.test.ts @@ -1,5 +1,6 @@ import { test, expect } from 'vitest'; import fs from 'node:fs'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { makeIosSession } from '../../__tests__/test-utils/session-factories.ts'; import { flushDiagnosticsToSessionFile, @@ -14,9 +15,12 @@ test('createDaemonRuntimeSessionStore hides non-matching sessions and scopes wri const tempHome = mkdtempForTestSync('agent-device-runtime-session-home-'); const session = makeIosSession('qa-ios'); const writes: CommandSessionRecord[] = []; + const sessionStore = makeSessionStore(); + const ref = sessionStore.publish(session.name, session); const store = createDaemonRuntimeSessionStore({ sessionName: 'qa-ios', - getSession: () => session, + sessionStore, + ref, recordOptions: { includeSnapshot: true }, setRecord: (record) => { writes.push(record); @@ -57,3 +61,35 @@ test('createDaemonRuntimeSessionStore hides non-matching sessions and scopes wri fs.rmSync(tempHome, { recursive: true, force: true }); } }); + +test('runtime projections follow rebuilds and reject writes after their lifetime ends', async () => { + const sessionStore = makeSessionStore(); + const address = 'cwd:runtime:default'; + const ref = sessionStore.publish(address, makeIosSession('default')); + const writes: string[] = []; + const runtime = createDaemonRuntimeSessionStore({ + sessionName: address, + sessionStore, + ref, + setRecord: (_record, current) => { + writes.push(current!.appName!); + }, + }); + sessionStore.update(ref, { appName: 'Rebuilt' }); + expect(await runtime.get(address)).toMatchObject({ appName: 'Rebuilt' }); + await runtime.set({ name: address }); + expect(writes).toEqual(['Rebuilt']); + sessionStore.retire(ref); + const successor = sessionStore.publish( + address, + makeIosSession('default', { appName: 'Successor' }), + ); + expect(await runtime.get(address)).toBeUndefined(); + expect(() => runtime.set({ name: address })).toThrowError( + expect.objectContaining({ + details: expect.objectContaining({ reason: 'session_lifetime_ended' }), + }), + ); + expect(writes).toEqual(['Rebuilt']); + expect(sessionStore.requireCurrent(successor)).toBe(successor.session); +}); diff --git a/src/daemon/__tests__/screen-recording-session-binding.test.ts b/src/daemon/__tests__/screen-recording-session-binding.test.ts new file mode 100644 index 0000000000..520b6b3452 --- /dev/null +++ b/src/daemon/__tests__/screen-recording-session-binding.test.ts @@ -0,0 +1,60 @@ +import { expect, test, vi } from 'vitest'; +import { createScreenRecordingLiveHandle } from '@agent-device/capture-kit'; +import { PendingTransferGuard } from '@agent-device/contracts/async-lifecycle'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { makeRecordingSession } from './session-teardown.fixtures.ts'; +import { bindRecordOnlyScreenRecording } from '../screen-recording-session-binding.ts'; +import { createScreenRecordingAdmissionLedger } from '@agent-device/capture-kit/screen-recording-admission-ledger'; +import { + adoptStartedScreenRecording, + screenRecordingDurableResource, +} from '@agent-device/capture-kit/screen-recording-session-resource'; + +test('shutdown refuses draft publication while retaining unconfirmed recording cleanup evidence', async () => { + const store = makeSessionStore(); + const session = makeRecordingSession({ + name: 'draft', + sessionStore: store, + finish: async () => ({ status: 'cleanup-pending', reason: 'cleanup-unconfirmed' }), + }); + const { handle: initial, envelope } = session.screenRecording!; + const cleanup = vi.fn( + async () => ({ status: 'cleanup-pending', reason: 'cleanup-unconfirmed' }) as const, + ); + const handle = createScreenRecordingLiveHandle(initial.inspect(), { + finish: async () => ({ status: 'cleanup-pending', reason: 'cleanup-unconfirmed' }), + forceCleanup: cleanup, + }); + const draft = bindRecordOnlyScreenRecording(store, 'draft', { + ...session, + screenRecording: undefined, + }); + store.closeAdmission(); + await expect( + adoptStartedScreenRecording({ + binding: draft.binding, + admissionLedger: createScreenRecordingAdmissionLedger(), + device: session.device, + owner: envelope.owner, + fence: envelope.fence, + pendingHandle: new PendingTransferGuard(handle), + envelope, + throwIfCanceled: () => {}, + }), + ).rejects.toMatchObject({ details: { reason: 'daemon_shutting_down' } }); + expect(cleanup).toHaveBeenCalledOnce(); + expect(store.lookup('draft')).toBeUndefined(); + const record = screenRecordingDurableResource.store.read( + screenRecordingDurableResource.store.resolvePath(draft.binding.sessionDir), + ); + expect(record).toMatchObject({ + status: 'decoded', + envelope: { + lifecycle: 'open', + descriptor: envelope.descriptor, + metadata: { phase: 'cleanup-pending' }, + }, + }); + if (record.status !== 'decoded') throw new Error('Expected recovery evidence'); + expect(record.envelope.metadata?.runtimeContractInvalid).toBeUndefined(); +}); diff --git a/src/daemon/__tests__/selector-capture-runtime.test.ts b/src/daemon/__tests__/selector-capture-runtime.test.ts index 9384dc7553..d90131a822 100644 --- a/src/daemon/__tests__/selector-capture-runtime.test.ts +++ b/src/daemon/__tests__/selector-capture-runtime.test.ts @@ -52,6 +52,7 @@ test('selector capture cache is keyed by scoped presentation options', async () })); const runtime = createSelectorCaptureRuntime({ + ref: sessionStore.lookup(sessionName), device: session.device, session, sessionStore, @@ -225,6 +226,7 @@ function proofRuntime(params: { const consumedSnapshot: { state?: SnapshotState } = {}; const captureProof: RequestCaptureProof = {}; const runtime = createSelectorCaptureRuntime({ + ref: sessionStore.lookup(params.sessionName), device: session.device, session, sessionStore, @@ -318,6 +320,7 @@ function makeCaptureRuntime(sessionName: string) { const session = makeIosSession(sessionName); sessionStore.set(sessionName, session); const runtime = createSelectorCaptureRuntime({ + ref: sessionStore.lookup(sessionName), device: session.device, session, sessionStore, @@ -333,3 +336,44 @@ function makeCaptureRuntime(sessionName: string) { }); return { runtime, sessionName, sessionStore }; } + +test('a held selector capture updates the matching rebuilt record without restoring its old fields', async () => { + const sessionStore = makeSessionStore(); + const address = 'cwd:selector-capture:default'; + const ref = sessionStore.publish(address, makeIosSession('default')); + let release!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + boundCapture.mockImplementationOnce(async () => { + await held; + return { + backend: 'xctest', + producer: 'apple-runner', + nodes: [{ index: 0, type: 'Button', label: 'Late capture' }], + }; + }); + const runtime = createSelectorCaptureRuntime({ + ref, + device: ref.session.device, + session: ref.session, + sessionStore, + sessionName: address, + capture: boundCapture, + req: { token: 't', session: address, command: 'get', positionals: [], flags: {} }, + }); + const running = runtime.capture({ flags: {} }); + try { + await vi.waitFor(() => expect(boundCapture).toHaveBeenCalledOnce()); + sessionStore.update(ref, { appName: 'Intervening rebuild' }); + release(); + await running; + expect(sessionStore.requireCurrent(ref).appName).toBe('Intervening rebuild'); + expect(sessionStore.requireCurrent(ref).snapshot?.nodes[0]?.label).toBe('Late capture'); + expect(ref.session.snapshot).toBeUndefined(); + expect(sessionStore.get('default')).toBeUndefined(); + } finally { + release(); + await running.catch(() => {}); + } +}); diff --git a/src/daemon/__tests__/session-artifact-paths.test.ts b/src/daemon/__tests__/session-artifact-paths.test.ts new file mode 100644 index 0000000000..c76caa9ffb --- /dev/null +++ b/src/daemon/__tests__/session-artifact-paths.test.ts @@ -0,0 +1,29 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { AppError } from '@agent-device/kernel/errors'; +import { resolveSessionDir } from '../session-artifact-paths.ts'; +import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; + +test('resolveSessionDir keeps every session dir beneath the sessions dir', () => { + const sessionsDir = path.join( + mkdtempForTestSync('agent-device-tests'), + 'agent-device-tests', + 'sessions', + ); + assert.equal(resolveSessionDir(sessionsDir, 'a/b:c d'), path.join(sessionsDir, 'a_b_c_d')); + // `.` and `..` survive `safeSessionName` unchanged, so without an explicit + // refusal `path.join` resolves them to the sessions dir itself and its parent + // (the daemon state dir): a remote caller's `--session ..` would then land + // app.log / runner.log / requests/*.ndjson outside the sessions tree. + for (const name of ['.', '..', '']) { + assert.throws( + () => resolveSessionDir(sessionsDir, name), + (error: unknown) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + /session name/i.test(error.message), + `expected resolveSessionDir(${JSON.stringify(name)}) to reject`, + ); + } +}); diff --git a/src/daemon/__tests__/session-capture-binding.test.ts b/src/daemon/__tests__/session-capture-binding.test.ts new file mode 100644 index 0000000000..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-snapshot.test.ts b/src/daemon/__tests__/session-snapshot.test.ts index b0b64ef0f2..c7b6fa2458 100644 --- a/src/daemon/__tests__/session-snapshot.test.ts +++ b/src/daemon/__tests__/session-snapshot.test.ts @@ -5,7 +5,7 @@ import { markSessionPartialRefsIssued, resolveRefStalenessWarning, setSessionSnapshot, - setSnapshotLineage, + setCommandSnapshot, STALE_SNAPSHOT_REFS_WARNING, } from '../session-snapshot.ts'; import { @@ -179,7 +179,8 @@ test('a ref pinned before a diff keeps resolving: the diff advances the counter, // `diff` replaces the stored tree, so lineage advances the counter — but it passes // `issuesRefsToClient: false`, so it never reactivates the frame. const afterDiff: SessionState = { ...session }; - setSnapshotLineage(afterDiff, { + setCommandSnapshot(afterDiff, { + snapshot: afterDiff.snapshot!, scopeSource: undefined, keptCurrentSnapshot: false, previousGeneration: session.snapshotGeneration, @@ -211,7 +212,8 @@ test('keeping the current snapshot leaves the counter alone', () => { setSessionSnapshot(session, makeSnapshot()); const before = session.snapshotGeneration; - setSnapshotLineage(session, { + setCommandSnapshot(session, { + snapshot: session.snapshot!, scopeSource: undefined, keptCurrentSnapshot: true, previousGeneration: before, diff --git a/src/daemon/__tests__/session-store-lifetime.test.ts b/src/daemon/__tests__/session-store-lifetime.test.ts new file mode 100644 index 0000000000..f834cc2f96 --- /dev/null +++ b/src/daemon/__tests__/session-store-lifetime.test.ts @@ -0,0 +1,173 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { test } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; +import { + makeSession, + makeRepairCompleteSession, + makeRepairArmedSession, + authoringPublication, +} from '../../__tests__/test-utils/session-factories.ts'; +import { makeSessionStore } 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); +}); + +test('script writes use the latest matching record and refuse a retired lifetime', () => { + const store = makeSessionStore(); + const ref = store.publish(ADDRESS, makeSession('default')); + store.update(ref, { + scriptPublication: authoringPublication('armed'), + actions: [{ ts: 1, command: 'click', positionals: ['id="late-action"'], flags: {} }], + }); + const result = store.writeSessionLog(ref); + assert.equal(result.written, true); + if (result.written) assert.match(fs.readFileSync(result.path, 'utf8'), /late-action/); + store.retire(ref); + const successor = store.publish(ADDRESS, makeRepairCompleteSession('default')); + assert.throws(() => store.writeSessionLog(ref), ended); + store.finalizeRepairTeardown(ref); + const state = store.requireCurrent(successor).scriptPublication; + assert.equal(state?.kind, 'repair'); + if (state?.kind === 'repair') assert.equal(state.status, 'complete'); + assert.equal(successor.session.actions.length, 0); +}); + +test('repair tombstones follow the scoped address and cannot be written by a retired ref', () => { + const store = makeSessionStore(); + const ref = store.publish(ADDRESS, makeRepairArmedSession('default')); + store.update(ref, { + scriptPublication: { + kind: 'repair', + status: 'armed', + boundary: 0, + target: { kind: 'default', force: false }, + sourcePath: '/latest.ad', + }, + }); + store.writeRepairTombstone(ref); + assert.equal(store.readRepairTombstone(ADDRESS)?.owner, ADDRESS); + assert.equal(store.readRepairTombstone(ADDRESS)?.sourcePath, '/latest.ad'); + assert.equal(store.readRepairTombstone('default'), undefined); + store.retire(ref); + store.clearRepairTombstone(ADDRESS); + const successor = store.publish(ADDRESS, makeRepairArmedSession('default')); + store.writeRepairTombstone(ref); + assert.equal(store.readRepairTombstone(ADDRESS), undefined); + assert.equal(store.requireCurrent(successor), successor.session); +}); diff --git a/src/daemon/__tests__/session-store.test.ts b/src/daemon/__tests__/session-store.test.ts index 05fe386324..0e00cfef09 100644 --- a/src/daemon/__tests__/session-store.test.ts +++ b/src/daemon/__tests__/session-store.test.ts @@ -1,10 +1,8 @@ +import { storeSessionForTest } from '../../__tests__/test-utils/store-factory.ts'; import { test } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; -// oxlint-disable-next-line no-restricted-imports -- asserts a path under os.homedir -import os from 'node:os'; import path from 'node:path'; -import { AppError } from '@agent-device/kernel/errors'; import { SessionStore } from '../session-store.ts'; import type { SessionState } from '../session-state.ts'; import { buildRequestFinishedEvent } from '@agent-device/session-journal/session-event-log'; @@ -86,7 +84,7 @@ function recordClose(store: SessionStore, session: SessionState): void { } function writeScript({ root, store, session }: SessionStoreFixture): string { - store.writeSessionLog(session); + store.writeSessionLog(storeSessionForTest(store, session)); return readWrittenSessionScript(root); } @@ -96,19 +94,6 @@ function assertScriptMatches(script: string, patterns: RegExp[]): void { } } -test('expandHome resolves tilde, relative-with-cwd, and absolute paths', () => { - const homePath = SessionStore.expandHome('~/flows/replay.ad'); - assert.equal(homePath.startsWith(os.homedir()), true); - assert.equal(homePath.endsWith(path.join('flows', 'replay.ad')), true); - - const relativePath = SessionStore.expandHome('workflows/replay.ad', '/tmp/agent-device-cwd'); - assert.equal(relativePath, path.resolve('/tmp/agent-device-cwd', 'workflows/replay.ad')); - - const absoluteInput = path.resolve('/tmp', 'agent-device-absolute.ad'); - const absolutePath = SessionStore.expandHome(absoluteInput, '/tmp/ignored-cwd'); - assert.equal(absolutePath, absoluteInput); -}); - test('defaultTracePath sanitizes session name', () => { const store = new SessionStore( path.join(mkdtempForTestSync('agent-device-tests'), 'agent-device-tests'), @@ -119,30 +104,6 @@ test('defaultTracePath sanitizes session name', () => { assert.match(tracePath, /\.trace\.log$/); }); -test('resolveSessionDir keeps every session dir beneath the sessions dir', () => { - const sessionsDir = path.join( - mkdtempForTestSync('agent-device-tests'), - 'agent-device-tests', - 'sessions', - ); - const store = new SessionStore(sessionsDir); - assert.equal(store.resolveSessionDir('a/b:c d'), path.join(sessionsDir, 'a_b_c_d')); - // `.` and `..` survive `safeSessionName` unchanged, so without an explicit - // refusal `path.join` resolves them to the sessions dir itself and its parent - // (the daemon state dir): a remote caller's `--session ..` would then land - // app.log / runner.log / requests/*.ndjson outside the sessions tree. - for (const name of ['.', '..', '']) { - assert.throws( - () => store.resolveSessionDir(name), - (error: unknown) => - error instanceof AppError && - error.code === 'INVALID_ARGS' && - /session name/i.test(error.message), - `expected resolveSessionDir(${JSON.stringify(name)}) to reject`, - ); - } -}); - test('session lease metadata round-trips through the store', () => { const { store, session } = makeFixture('agent-device-session-lease-'); session.lease = { @@ -174,7 +135,7 @@ test('saveScript flag enables .ad session log writing', () => { recordOpen(store, session); recordClose(store, session); - store.writeSessionLog(session); + store.writeSessionLog(storeSessionForTest(store, session)); assert.equal(listSessionScriptFiles(root).length, 1); }); @@ -437,7 +398,7 @@ test('saveScript path writes session log to custom location', async () => { recordOpen(store, session, { platform: 'ios', saveScript: customPath }); recordClose(store, session); - store.writeSessionLog(session); + store.writeSessionLog(storeSessionForTest(store, session)); await store.flushEvents(session.name); assert.equal(fs.existsSync(customPath), true); assert.equal(fs.existsSync(store.resolveEventLogPath(session.name)), true); @@ -763,7 +724,7 @@ test('writeRepairTombstone/readRepairTombstone round-trips owner + source path', sourcePath: '/flows/login.ad', }); - store.writeRepairTombstone(session); + store.writeRepairTombstone(storeSessionForTest(store, session)); const tombstone = store.readRepairTombstone('default'); assert.ok(tombstone); assert.equal(tombstone?.owner, 'default'); @@ -776,7 +737,7 @@ test('readRepairTombstone returns undefined once the tombstone has expired', () const store = new SessionStore(path.join(root, 'sessions')); const session = makeSession('default'); // TTL 0 => expiresAt <= now => already stale. - store.writeRepairTombstone(session, 0); + store.writeRepairTombstone(storeSessionForTest(store, session), 0); assert.equal(store.readRepairTombstone('default'), undefined); }); @@ -784,7 +745,7 @@ test('clearRepairTombstone removes a tombstone (a fresh replay --save-script cle const root = mkdtempForTestSync('agent-device-tombstone-clear-'); const store = new SessionStore(path.join(root, 'sessions')); const session = makeSession('default'); - store.writeRepairTombstone(session); + store.writeRepairTombstone(storeSessionForTest(store, session)); assert.ok(store.readRepairTombstone('default')); store.clearRepairTombstone('default'); @@ -818,7 +779,7 @@ test('BLOCKER 2: finalizeRepairTeardown of a COMPLETE transaction whose commit F session.actions = [{ ts: 1, command: 'open', positionals: ['Demo'], flags: {} }]; // Idle-reap/shutdown teardown (never routes through close's handler). - store.finalizeRepairTeardown(session); + store.finalizeRepairTeardown(storeSessionForTest(store, session)); // The prior complete artifact is untouched — teardown's failed commit // never clobbers it. @@ -859,7 +820,7 @@ test('BLOCKER 3: finalizeRepairTeardown auto-commit records a terminal close, pr // The source plan's terminal `close` was already skipped-while-armed // (Fix 3) — `session.actions` never gained one. Idle-reap/shutdown teardown // must synthesize it itself before auto-committing. - store.finalizeRepairTeardown(session); + store.finalizeRepairTeardown(storeSessionForTest(store, session)); assert.equal( session.scriptPublication?.kind === 'repair' ? session.scriptPublication.status : undefined, diff --git a/src/daemon/__tests__/snapshot-command-runtime.test.ts b/src/daemon/__tests__/snapshot-command-runtime.test.ts index b43dafba60..2a17a1d6ea 100644 --- a/src/daemon/__tests__/snapshot-command-runtime.test.ts +++ b/src/daemon/__tests__/snapshot-command-runtime.test.ts @@ -85,3 +85,60 @@ for (const command of ['snapshot', 'diff snapshot'] as const) { } }); } + +for (const change of ['rebuild', 'replace'] as const) { + test(`snapshot completion respects a scoped lifetime after ${change}`, async () => { + const sessionStore = makeSessionStore(); + const address = 'cwd:snapshot-completion:default'; + const ref = sessionStore.publish( + address, + makeAndroidSession('default', { trace: { outPath: 'prior-trace', startedAt: 0 } }), + ); + const entered = deferred(); + const release = deferred(); + captureMock.mockImplementation(async () => { + entered.resolve(); + await release.promise; + return { + backend: 'uiautomator', + nodes: [{ index: 0, type: 'Button', label: 'Captured' }], + }; + }); + const running = dispatchSnapshotViaRuntime({ + req: { command: 'snapshot', positionals: [], token: 't', session: address }, + sessionName: address, + logPath: '/dev/null', + sessionStore, + ...snapshotRuntimeFixture(), + }); + const result = running.then( + (response) => ({ response }), + (error: unknown) => ({ error }), + ); + try { + await entered.promise; + if (change === 'rebuild') { + const trace = { outPath: 'intervening-trace', startedAt: 1 }; + sessionStore.update(ref, { trace }); + release.resolve(); + expect(await result).toMatchObject({ response: { ok: true } }); + const current = sessionStore.requireCurrent(ref); + expect(current.trace).toBe(trace); + expect(current.snapshot?.nodes[0]?.label).toBe('Captured'); + } else { + sessionStore.retire(ref); + const successor = sessionStore.publish(address, makeAndroidSession('default')); + release.resolve(); + expect(await result).toMatchObject({ + error: { details: { reason: 'session_lifetime_ended' } }, + }); + expect(sessionStore.requireCurrent(successor)).toBe(successor.session); + expect(successor.session.snapshot).toBeUndefined(); + } + expect(sessionStore.lookup('default')).toBeUndefined(); + } finally { + release.resolve(); + await result; + } + }); +} diff --git a/src/daemon/app-log-session-resource.ts b/src/daemon/app-log-session-resource.ts index e86d50f165..1099c5811e 100644 --- a/src/daemon/app-log-session-resource.ts +++ b/src/daemon/app-log-session-resource.ts @@ -15,7 +15,8 @@ import { } from '@agent-device/capture-kit/durable-capture-resource'; import { appLogResourceStore } from './app-log-resource-store.ts'; import type { SessionStore } from './session-store.ts'; -import type { SessionState } from './session-state.ts'; +import type { SessionRef, SessionState } from './session-state.ts'; +import { bindSessionCapture } from './session-capture-binding.ts'; export type AppLogSessionSnapshot = Readonly<{ active: boolean; @@ -30,16 +31,11 @@ export type AppLogSessionSnapshot = Readonly<{ export const appLogDurableResource = createDurableCaptureResource< 'app-log', AppLogLiveHandle, - AppLogCompletion, - SessionState + AppLogCompletion >({ resourceKind: 'app-log', displayName: 'app-log', store: appLogResourceStore, - sessionSlot: { - read: (session) => session.appLog, - replace: (session, appLog) => ({ ...session, appLog, appLogFailure: undefined }), - }, completionMetadata: (completion) => ({ backend: completion.backend, outputPath: completion.outputPath, @@ -76,10 +72,8 @@ export function inspectSessionAppLog(session: SessionState): AppLogSessionSnapsh export function adoptStartedSessionAppLog(params: { admissionLedger: AppLogAdmissionLedger; - session: SessionState; - sessionName: string; + ref: SessionRef; sessionStore: SessionStore; - resourcePath: string; device: DeviceInfo; owner: RuntimeOwnerRef; fence: ResourceOwnershipFence; @@ -87,38 +81,42 @@ export function adoptStartedSessionAppLog(params: { envelope: DurableResourceEnvelope<'app-log'>; throwIfCanceled(): void; }): Promise { - return appLogDurableResource.adoptStarted(params); + return appLogDurableResource.adoptStarted({ + ...params, + binding: bindSessionAppLog(params.sessionStore, params.ref), + }); } export function finishSessionAppLog(params: { - session: SessionState; - sessionName: string; + ref: SessionRef; sessionStore: SessionStore; - resourcePath: string; intent: DurableCaptureFinishIntent; }): Promise { - return appLogDurableResource.finishLive(params); + return appLogDurableResource.finishLive({ + binding: bindSessionAppLog(params.sessionStore, params.ref), + intent: params.intent, + }); } export function forceCleanupSessionAppLog(params: { - session: SessionState; - sessionName?: string; - sessionStore?: SessionStore; - resourcePath: string; + ref: SessionRef; + sessionStore: SessionStore; }): Promise { - return appLogDurableResource.forceCleanupLive(params); + return appLogDurableResource.forceCleanupLive({ + binding: bindSessionAppLog(params.sessionStore, params.ref), + }); } export function recordSessionAppLogFailure(params: { - session: SessionState; - sessionName: string; + ref: SessionRef; sessionStore: SessionStore; error: unknown; backend?: LogBackend; }): ReturnType { const normalized = normalizeError(params.error); - params.sessionStore.set(params.sessionName, { - ...params.session, + const current = params.sessionStore.resolveCurrent(params.ref); + if (!current || current.appLog) return normalized; + params.sessionStore.update(params.ref, { appLog: undefined, appLogFailure: { backend: params.backend, @@ -131,12 +129,19 @@ export function recordSessionAppLogFailure(params: { } export function clearSessionAppLogFailure(params: { - session: SessionState; - sessionName: string; + ref: SessionRef; sessionStore: SessionStore; }): void { - params.sessionStore.set(params.sessionName, { - ...params.session, + params.sessionStore.update(params.ref, { appLogFailure: undefined, }); } + +export function bindSessionAppLog(sessionStore: SessionStore, ref: SessionRef) { + return bindSessionCapture(sessionStore, ref, { + read: (session) => session.appLog, + write: (appLog) => { + sessionStore.update(ref, { appLog, appLogFailure: undefined }); + }, + }); +} diff --git a/src/daemon/daemon-stop.ts b/src/daemon/daemon-stop.ts index bf5be1697b..c5e53eefcf 100644 --- a/src/daemon/daemon-stop.ts +++ b/src/daemon/daemon-stop.ts @@ -1,7 +1,6 @@ -import fs from 'node:fs'; import { AppError } from '@agent-device/kernel/errors'; -import { stopDaemonProcess } from '../daemon-process.ts'; -import { sleep } from '@agent-device/host-kit/retry'; +import type { DaemonRetirementResult } from '../daemon-registration-owner.ts'; +import type { OwnerIdentity } from '@agent-device/host-kit/process'; import type { DaemonPaths } from '../daemon-resolution.ts'; import { readRegisteredDaemonIdentity } from '../daemon-registration.ts'; @@ -9,7 +8,6 @@ import type { DeviceClaimRecord, ProviderReleaseRecord } from '../daemon-shutdow const DAEMON_STOP_GRACE_TIMEOUT_MS = 10_000; const DAEMON_STOP_KILL_TIMEOUT_MS = 2_000; -const DAEMON_STOP_METADATA_WAIT_MS = 1_000; export type DaemonStopResult = { stopped: boolean; @@ -45,25 +43,19 @@ export async function stopDaemon(params: { }): Promise { const info = readRegisteredDaemonIdentity(params.paths.infoPath); if (!info) return notRunningResult(); - const termination = await stopDaemonProcess(info, { + const { stopAndRetireDaemon } = await import('../daemon-registration-owner.ts'); + const retirement = await stopAndRetireDaemon({ + paths: params.paths, + observed: info, mode: 'graceful', termTimeoutMs: params.graceTimeoutMs ?? DAEMON_STOP_GRACE_TIMEOUT_MS, killTimeoutMs: params.killTimeoutMs ?? DAEMON_STOP_KILL_TIMEOUT_MS, }); - if (termination.status === 'retained') { - throw new AppError('COMMAND_FAILED', 'Daemon termination could not be confirmed.', { - pid: info.pid, - processStartTime: info.startTime, - reason: 'daemon_exit_unconfirmed', - terminationReason: termination.reason, - signal: termination.signal, - }); - } - if (termination.status === 'not-running' || termination.mode === 'already-exited') { + if (retirement.status === 'retained' && retirement.termination?.status === 'not-running') return notRunningResult(); - } - if (termination.mode === 'graceful') { - await waitForDaemonMetadataRemoval(params.paths, DAEMON_STOP_METADATA_WAIT_MS); + if (retirement.status !== 'retired') throw daemonRetirementError(info, retirement); + if (retirement.termination.mode === 'already-exited') return notRunningResult(); + if (retirement.termination.mode === 'graceful') { return { stopped: true, mode: 'graceful', @@ -92,6 +84,27 @@ export async function stopDaemon(params: { }; } +function daemonRetirementError( + info: OwnerIdentity, + retirement: Exclude, +): AppError { + const termination = retirement.status === 'retained' ? retirement.termination : undefined; + const failure = termination?.status === 'retained' ? termination : undefined; + const error = retirement.status === 'retained' ? retirement.error : undefined; + const { hint, diagnosticId, logPath } = error ?? {}; + return new AppError('COMMAND_FAILED', 'Daemon retirement could not be confirmed.', { + pid: info.pid, + processStartTime: info.startTime, + reason: failure ? 'daemon_exit_unconfirmed' : 'daemon_retirement_unconfirmed', + terminationReason: failure?.reason, + signal: failure?.signal, + retirement, + hint, + diagnosticId, + logPath, + }); +} + export function readDaemonStopIdentity( infoPath: string, ): { pid: number; processStartTime: string } | null { @@ -100,14 +113,6 @@ export function readDaemonStopIdentity( return { pid: info.pid, processStartTime: info.startTime }; } -async function waitForDaemonMetadataRemoval(paths: DaemonPaths, timeoutMs: number): Promise { - const startedAt = Date.now(); - while (Date.now() - startedAt < timeoutMs) { - if (!fs.existsSync(paths.infoPath) && !fs.existsSync(paths.lockPath)) return; - await sleep(25); - } -} - function notRunningResult(): DaemonStopResult { return { stopped: false, diff --git a/src/daemon/device/device-claim-owner-recovery.ts b/src/daemon/device/device-claim-owner-recovery.ts index c8b60069ee..f57a6c641f 100644 --- a/src/daemon/device/device-claim-owner-recovery.ts +++ b/src/daemon/device/device-claim-owner-recovery.ts @@ -4,7 +4,11 @@ import { createPlatformRuntimeGateway } from '../../platform-runtime.ts'; import { resolveDaemonPaths } from '../../daemon-resolution.ts'; import { createDeviceClaimReconciler } from './device-claim-reconciliation.ts'; import type { DeviceClaimReconciler } from './device-claims.ts'; -import { SessionStore } from '../session-store.ts'; +import { + resolveSessionDir, + resolveSessionAppLogPath, + resolveSessionAppLogPidPath, +} from '../session-artifact-paths.ts'; export type OwnerScopedClaimRecovery = { reconcile: DeviceClaimReconciler; @@ -49,17 +53,16 @@ function composeOwnerScopedClaimRecovery( scope: PlatformRequestScope, ): OwnerScopedClaimRecovery { const daemonPaths = resolveDaemonPaths(stateDir); - const sessionStore = new SessionStore(daemonPaths.sessionsDir); const gateway = createPlatformRuntimeGateway({ sessionsDir: daemonPaths.sessionsDir, ownedProcesses: createOwnedProcessRecordStore({ stateDir: daemonPaths.baseDir, sessionsDir: daemonPaths.sessionsDir, - resolveSessionDir: (sessionId) => sessionStore.resolveSessionDir(sessionId), + resolveSessionDir: (sessionId) => resolveSessionDir(daemonPaths.sessionsDir, sessionId), }), resolveSessionArtifacts: (sessionId) => ({ - outputPath: sessionStore.resolveAppLogPath(sessionId), - pidPath: sessionStore.resolveAppLogPidPath(sessionId), + outputPath: resolveSessionAppLogPath(daemonPaths.sessionsDir, sessionId), + pidPath: resolveSessionAppLogPidPath(daemonPaths.sessionsDir, sessionId), }), }); return { diff --git a/src/daemon/generic-settle.ts b/src/daemon/generic-settle.ts index e18ca7f965..70e1ead5d6 100644 --- a/src/daemon/generic-settle.ts +++ b/src/daemon/generic-settle.ts @@ -15,7 +15,7 @@ import type { BoundContextFromFlags } from './context.ts'; import { issueSettleRefs } from './session-snapshot.ts'; import type { SessionStore } from './session-store.ts'; import type { DaemonRequest, DaemonResponse } from './daemon-request.ts'; -import type { SessionState } from './session-state.ts'; +import type { SessionRef, SessionState } from './session-state.ts'; /** * `--settle` on the generic daemon route (#1638): `scroll` and `back` change @@ -55,6 +55,7 @@ export type GenericSettleObserver = () => Promise export type GenericSettlePlan = { response: DaemonResponse } | { observe?: GenericSettleObserver }; type GenericSettleContext = { + sessionRef: SessionRef | undefined; req: DaemonRequest; session: SessionState; sessionName: string; @@ -109,7 +110,7 @@ async function observeSettled( session: context.sessionName, requestId: context.req.meta?.requestId, }); - const refsGeneration = issueSettleRefs(context.session, observation); + const refsGeneration = issueSettleRefs(context.sessionRef, context.sessionStore, observation); return refsGeneration === undefined ? observation : { ...observation, refsGeneration }; } @@ -120,6 +121,7 @@ function createGenericSettleRuntime( return createInteractionRuntime({ req: context.req, sessionName: context.sessionName, + sessionRef: context.sessionRef, logPath: context.logPath, sessionStore: context.sessionStore, contextFromFlags: context.contextFromFlags, diff --git a/src/daemon/handlers/__tests__/session-device-claims.test.ts b/src/daemon/handlers/__tests__/session-device-claims.test.ts index d45941b2c1..bb60b6ec2f 100644 --- a/src/daemon/handlers/__tests__/session-device-claims.test.ts +++ b/src/daemon/handlers/__tests__/session-device-claims.test.ts @@ -75,7 +75,6 @@ const mockResolveTargetDevice = vi.mocked(resolveTargetDevice); const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); const mockApplyRuntimeHints = vi.mocked(applyRuntimeHintValues); const mockResolveAndroidPackage = vi.mocked(resolveAndroidPackageForOpen); -const roots: string[] = []; const reconcileOrphanedDeviceClaim = async () => ({ status: 'retained' as const, reason: 'test-no-recovery', @@ -115,14 +114,12 @@ afterEach(() => { mockApplyRuntimeHints.mockResolvedValue(undefined); mockResolveAndroidPackage.mockResolvedValue(undefined); delete process.env.AGENT_DEVICE_CLAIMS_DIR; - for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); }); function setup(): { store: SessionStore; stateDir: string } { const stateDir = mkdtempForTestSync('agent-device-session-device-claim-'); const claimsDir = path.join(stateDir, 'claims'); process.env.AGENT_DEVICE_CLAIMS_DIR = claimsDir; - roots.push(stateDir); return { store: new SessionStore(path.join(stateDir, 'sessions')), stateDir }; } diff --git a/src/daemon/handlers/react-native.ts b/src/daemon/handlers/react-native.ts index fadabcab01..028fec887b 100644 --- a/src/daemon/handlers/react-native.ts +++ b/src/daemon/handlers/react-native.ts @@ -17,6 +17,7 @@ import { isSparseSnapshotQualityVerdict } from '@agent-device/capture-kit/snapsh import type { DaemonResponse } from '../daemon-request.ts'; import type { SessionState } from '../session-state.ts'; import { + bindInteractionSession, captureSnapshotForSession, finalizeTouchInteraction, type InteractionRouteInput, @@ -28,6 +29,7 @@ import { errorResponse, noActiveSessionError } from '@agent-device/kernel/contra export async function handleReactNativeCommands( params: InteractionRouteInput, ): Promise { + params = bindInteractionSession(params); const { req, sessionName, sessionStore } = params; if (req.command !== PUBLIC_COMMANDS.reactNative) return null; const parsed = parseReactNativeArgs(req.positionals ?? []); @@ -62,7 +64,7 @@ export async function handleReactNativeCommands( try { const snapshot = await captureSnapshotForSession( - session, + params.sessionRef!, req.flags, sessionStore, params.contextFromFlags, @@ -161,7 +163,7 @@ async function executeReactNativeOverlayDismiss( expireRefFrame(session); const data = await tapPoint(target.point); const actionFinishedAt = Date.now(); - const verification = await verifyReactNativeOverlayDismissal(params, session); + const verification = await verifyReactNativeOverlayDismissal(params); const responseData = stripUndefined({ ...readSnapshotNodesReferenceFrame(snapshot.nodes), ...data, @@ -192,17 +194,14 @@ async function executeReactNativeOverlayDismiss( }); } -async function verifyReactNativeOverlayDismissal( - params: InteractionRouteInput, - session: SessionState, -): Promise<{ +async function verifyReactNativeOverlayDismissal(params: InteractionRouteInput): Promise<{ verified: boolean; verificationWarning?: string; nextCommand?: string; }> { const { req, sessionStore } = params; const verificationSnapshot = await captureSnapshotForSession( - session, + params.sessionRef!, req.flags, sessionStore, params.contextFromFlags, diff --git a/src/daemon/handlers/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/handlers/session-app-deployment.ts b/src/daemon/handlers/session-app-deployment.ts index edf468e306..79768d7158 100644 --- a/src/daemon/handlers/session-app-deployment.ts +++ b/src/daemon/handlers/session-app-deployment.ts @@ -1,3 +1,4 @@ +import { expandSessionPath } from '@agent-device/host-kit/session-paths'; import fs from 'node:fs'; import type { AppDeploymentResult } from '@agent-device/contracts/app-deployment-runtime'; import { @@ -9,7 +10,7 @@ import { readNotificationPayload } from '../dispatch-payload.ts'; import { cleanupUploadedArtifact, prepareUploadedArtifact } from '../artifact-tracking.ts'; import { expireRefFrame } from '../ref-frame.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; -import { SessionStore } from '../session-store.ts'; +import type { SessionStore } from '../session-store.ts'; import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; import type { SessionState } from '../session-state.ts'; import { resolvePayloadInput } from '../payload-input.ts'; @@ -62,7 +63,7 @@ export async function handleAppDeploymentCommand(params: { try { const appPath = uploadedArtifactId ? prepareUploadedArtifact(uploadedArtifactId, req.meta?.tenantId) - : SessionStore.expandHome(target.appPathInput); + : expandSessionPath(target.appPathInput); if (!fs.existsSync(appPath)) { return errorResponse('INVALID_ARGS', `App binary not found: ${appPath}`); } @@ -242,7 +243,7 @@ function resolvePushPayload(payloadArg: string, cwd?: string): string { const resolved = resolvePayloadInput(payloadArg, { subject: 'Push payload', cwd, - expandPath: (value, currentCwd) => SessionStore.expandHome(value, currentCwd), + expandPath: (value, currentCwd) => expandSessionPath(value, currentCwd), }); return resolved.kind === 'file' ? resolved.path : resolved.text; } diff --git a/src/daemon/handlers/session-script-publication.ts b/src/daemon/handlers/session-script-publication.ts index 3bc31f44c3..dc6826dac1 100644 --- a/src/daemon/handlers/session-script-publication.ts +++ b/src/daemon/handlers/session-script-publication.ts @@ -25,14 +25,15 @@ export function handleSessionScriptPublication(params: { ); } - const session = sessionStore.get(sessionName); - if (!session) { + const ref = sessionStore.lookup(sessionName); + if (!ref) { return failure( new AppError('SESSION_NOT_FOUND', `No active session "${sessionName}".`, { hint: 'Start a fresh journey with open --save-script[=], then retry.', }), ); } + const session = sessionStore.requireCurrent(ref); const eligibilityError = validatePublicationEligibility(session); if (eligibilityError) return failure(eligibilityError); @@ -42,7 +43,7 @@ export function handleSessionScriptPublication(params: { } retargetActivePublication(session, { explicitPath, liveForce: req.flags?.force }); - const result = sessionStore.writeSessionLog(session, { + const result = sessionStore.writeSessionLog(ref, { force: effectiveWriteForce(session, req.flags?.force), publication: 'active', }); diff --git a/src/daemon/handlers/trace-runtime.ts b/src/daemon/handlers/trace-runtime.ts index 43b8ee86c6..64f804f0b5 100644 --- a/src/daemon/handlers/trace-runtime.ts +++ b/src/daemon/handlers/trace-runtime.ts @@ -1,7 +1,8 @@ +import { expandSessionPath } from '@agent-device/host-kit/session-paths'; import fs from 'node:fs'; import path from 'node:path'; import type { TraceCommandResult } from '@agent-device/contracts/recording'; -import { SessionStore } from '../session-store.ts'; +import type { SessionStore } from '../session-store.ts'; import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; import type { SessionState } from '../session-state.ts'; import { recordSessionAction } from '../session-action-recorder.ts'; @@ -29,9 +30,7 @@ function startTrace( session: SessionState, ): DaemonResponse { if (session.trace) return errorResponse('INVALID_ARGS', 'trace already in progress'); - const outPath = SessionStore.expandHome( - req.positionals?.[1] ?? sessionStore.defaultTracePath(session), - ); + const outPath = expandSessionPath(req.positionals?.[1] ?? sessionStore.defaultTracePath(session)); fs.mkdirSync(path.dirname(outPath), { recursive: true }); fs.appendFileSync(outPath, ''); session.trace = { outPath, startedAt: Date.now() }; @@ -72,7 +71,7 @@ function stopTrace( function relocateTraceOutput(currentPath: string, requestedPath: string | undefined): string { if (!requestedPath) return currentPath; - const resolved = SessionStore.expandHome(requestedPath); + const resolved = expandSessionPath(requestedPath); fs.mkdirSync(path.dirname(resolved), { recursive: true }); if (fs.existsSync(currentPath)) fs.renameSync(currentPath, resolved); else fs.appendFileSync(resolved, ''); diff --git a/src/daemon/interaction/index.ts b/src/daemon/interaction/index.ts index 219b6d1082..221f606136 100644 --- a/src/daemon/interaction/index.ts +++ b/src/daemon/interaction/index.ts @@ -1,3 +1,4 @@ +import { bindInteractionSession } from './internal/interaction-session.ts'; import type { Rect } from '@agent-device/kernel/snapshot'; import { buildRuntimeCaptureInput } from '../snapshot-runtime-capture-input.ts'; import { setSessionSnapshot } from '../session-snapshot.ts'; @@ -15,16 +16,18 @@ export type { FindRouteInput, InteractionRouteInput } from './internal/types.ts' export { refMutationAdmissionResponse } from './internal/interaction-ref-policy.ts'; export { finalizeTouchInteraction } from './internal/interaction-runtime.ts'; +export { bindInteractionSession }; export { readSettleRequest, settleFlagGuardResponse }; export const captureSnapshotForSession: CaptureSnapshotForSession = async ( - session, + ref, flags, sessionStore, contextFromFlags, options, ) => { + const session = sessionStore.requireCurrent(ref); return await captureInteractionSnapshot({ session, flags, @@ -59,12 +62,7 @@ export const captureSnapshotForSession: CaptureSnapshotForSession = async ( return snapshot; }, publishSnapshot: (snapshot) => { - setSessionSnapshot(session, snapshot); - // The store owns the key a session answers to, and for an implicitly scoped session that is - // `cwd::` while `session.name` is only `default`. Storing by the name - // published a second address for the same session, which an implicit request can then read - // as two sessions in one workspace. - sessionStore.set(sessionStore.resolveStoredSessionName(session), session); + setSessionSnapshot(sessionStore.requireCurrent(ref), snapshot); }, }); }; @@ -77,7 +75,7 @@ export function createInteractionRuntime( }, ) { return createInteractionRuntimeForRoute({ - ...params, + ...bindInteractionSession(params), captureSnapshotForSession: params.captureSnapshotForSession ?? captureSnapshotForSession, }); } @@ -87,9 +85,10 @@ export async function handleFindCommands(params: FindRouteInput) { } export async function handleInteractionCommands(params: InteractionRouteInput) { + const bound = bindInteractionSession(params); const module = await import('./internal/interaction.ts'); return await module.handleInteractionCommands({ - ...params, + ...bound, captureSnapshotForSession: params.captureSnapshotForSession ?? captureSnapshotForSession, }); } diff --git a/src/daemon/interaction/internal/__tests__/interaction-gesture-drag.test.ts b/src/daemon/interaction/internal/__tests__/interaction-gesture-drag.test.ts index 50eed0ed2b..593cffce83 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-gesture-drag.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-gesture-drag.test.ts @@ -11,9 +11,9 @@ import { handleInteractionCommands } from '../../index.ts'; import { gestureRuntimeBindingsFixture } from './gesture-runtime-bindings.fixtures.ts'; const contextFromFlags = () => ({}); -const captureSnapshotForSession = async ( - session: import('../../../session-state.ts').SessionState, -) => session.snapshot!; +const captureSnapshotForSession = async ({ + session, +}: import('../../../session-state.ts').SessionRef) => session.snapshot!; let gestures = gestureRuntimeBindingsFixture(); beforeEach(() => { diff --git a/src/daemon/interaction/internal/__tests__/interaction-ios-tap-outcome.test.ts b/src/daemon/interaction/internal/__tests__/interaction-ios-tap-outcome.test.ts index d357fb77db..d0163f192d 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-ios-tap-outcome.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-ios-tap-outcome.test.ts @@ -311,7 +311,7 @@ test('a producer or generation switch cannot corroborate a failed tap', async () command: 'click', requestId: undefined, flags: {}, - session, + ref: sessionStore.lookup(sessionName)!, sessionStore, contextFromFlags, captureSnapshotForSession: async () => after, @@ -343,7 +343,7 @@ test('a capture of a system surface cannot corroborate a tap taken against the a command: 'click', requestId: undefined, flags: {}, - session, + ref: sessionStore.lookup(sessionName)!, sessionStore, contextFromFlags, captureSnapshotForSession: async () => after, diff --git a/src/daemon/interaction/internal/__tests__/interaction-settle.test.ts b/src/daemon/interaction/internal/__tests__/interaction-settle.test.ts index 66acb3966c..48cc20728e 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-settle.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-settle.test.ts @@ -3,10 +3,16 @@ import { legacyDispatchCapture } from '../../../__tests__/legacy-snapshot-captur import { test, expect, vi, beforeEach } from 'vitest'; import { createInteractionRuntime, handleInteractionCommands } from '../../index.ts'; import type { SessionStore } from '../../../session-store.ts'; -import type { SessionState } from '../../../session-state.ts'; +import type { SessionRef, SessionState } from '../../../session-state.ts'; import { buildSnapshotState } from '@agent-device/capture-kit/snapshot-state'; import { setSessionSnapshot } from '../../../session-snapshot.ts'; -import { activateCompleteRefFrame, expireRefFrame, refFrameState } from '../../../ref-frame.ts'; +import { + activateCompleteRefFrame, + expireRefFrame, + refFrameState, + refFrameTree, +} from '../../../ref-frame.ts'; +import { captureSnapshotWithInteractor } from '../../../snapshot-interactor-capture.ts'; import { makeSessionStore } from '../../../../__tests__/test-utils/store-factory.ts'; import { makeIosSession } from '../../../../__tests__/test-utils/session-factories.ts'; import { @@ -28,6 +34,10 @@ import { // beyond a few poll ticks. const mockCaptureSnapshotForSession = vi.hoisted(() => vi.fn()); +vi.mock('../../../snapshot-interactor-capture.ts', () => ({ + captureSnapshotWithInteractor: vi.fn(), +})); +const nativeCapture = vi.mocked(captureSnapshotWithInteractor); const BEFORE_NODES = [ { index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } }, @@ -54,7 +64,7 @@ const AFTER_NODES = [ ]; async function emulateCaptureSnapshotForSession( - session: SessionState, + ref: SessionRef, flags: CommandFlags | undefined, sessionStore: SessionStore, contextFromFlags: ( @@ -64,6 +74,7 @@ async function emulateCaptureSnapshotForSession( ) => Record, options: { interactiveOnly: boolean }, ) { + const session = sessionStore.requireCurrent(ref); const effectiveFlags = { ...(flags ?? {}), snapshotInteractiveOnly: options.interactiveOnly }; const snapshotData = (await legacyDispatchCapture( session.device, @@ -73,8 +84,7 @@ async function emulateCaptureSnapshotForSession( contextFromFlags(effectiveFlags, session.appBundleId, session.trace?.outPath), )) as Parameters[0]; const snapshot = buildSnapshotState(snapshotData ?? {}, effectiveFlags); - setSessionSnapshot(session, snapshot); - sessionStore.set(session.name, session); + setSessionSnapshot(sessionStore.requireCurrent(ref), snapshot); return snapshot; } @@ -158,6 +168,7 @@ beforeEach(() => { }); mockCaptureSnapshotForSession.mockReset(); mockCaptureSnapshotForSession.mockImplementation(emulateCaptureSnapshotForSession); + nativeCapture.mockReset(); }); const SETTLE_FLAGS = { settle: true, settleQuietMs: 25, timeoutMs: 2_000 }; @@ -237,6 +248,65 @@ test('press --settle responds with the settled diff, refsGeneration, and activat expect(session.snapshot?.nodes.some((node) => node.label === 'Welcome!')).toBe(true); }); +test('held touch settle publishes refs into the current record of its scoped lifetime', async () => { + const sessionStore = makeSessionStore(); + const address = 'cwd:touch-settle:default'; + const seeded = seedSession(address, sessionStore); + seeded.name = 'default'; + const ref = sessionStore.lookup(address)!; + let enter!: () => void; + let release!: () => void; + const entered = new Promise((resolve) => { + enter = resolve; + }); + const held = new Promise((resolve) => { + release = resolve; + }); + let captures = 0; + nativeCapture.mockImplementation(async () => { + captures += 1; + if (captures === 2) { + enter(); + await held; + } + return { + nodes: captures === 1 ? BEFORE_NODES : AFTER_NODES, + backend: 'xctest', + producer: 'apple-runner', + }; + }); + const running = handleInteractionCommands({ + req: { + token: 't', + session: address, + command: 'press', + positionals: ['label=Continue'], + flags: { ...SETTLE_FLAGS }, + }, + sessionName: address, + sessionStore, + contextFromFlags, + ...getRuntimeBindings(), + }); + try { + await entered; + expect(refFrameState(ref.session)).toBe('expired'); + sessionStore.update(ref, { trace: { outPath: 'rebuilt-trace', startedAt: 1 } }); + release(); + const settle = expectOkData(await running).settle as SettlePayload; + const current = sessionStore.requireCurrent(ref); + expect(current.snapshot?.nodes.some((node) => node.label === 'Welcome!')).toBe(true); + expect(refFrameState(current)).toBe('active'); + expect(refFrameTree(current)).toBe(current.snapshot); + expect(settle.refsGeneration).toBe(current.snapshotGeneration); + expect(refFrameState(ref.session)).toBe('expired'); + expect(sessionStore.lookup('default')).toBeUndefined(); + } finally { + release(); + await running.catch(() => {}); + } +}); + const MODAL_BEFORE_NODES = [ { index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } }, { @@ -391,7 +461,7 @@ test('a stalled settle capture receives its deadline signal and leaves the inter let observedAbort = false; mockCaptureSnapshotForSession.mockImplementation( async ( - _session: SessionState, + _session: SessionRef, _flags: CommandFlags | undefined, _sessionStore: SessionStore, _contextFromFlags: typeof contextFromFlags, diff --git a/src/daemon/interaction/internal/__tests__/interaction-snapshot-scope.test.ts b/src/daemon/interaction/internal/__tests__/interaction-snapshot-scope.test.ts index f05fba4b4e..80c8203d44 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-snapshot-scope.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-snapshot-scope.test.ts @@ -13,11 +13,15 @@ import { captureSnapshotForSession } from '../../index.ts'; // capture that dropped it here would return the unscoped tree with nothing left to notice (#1832 // C2, adversarial review of PR #1846). -const captured = vi.hoisted(() => ({ options: [] as SnapshotOptions[] })); +const captured = vi.hoisted(() => ({ + options: [] as SnapshotOptions[], + held: undefined as Promise | undefined, +})); vi.mock('../../../snapshot-interactor-capture.ts', () => ({ captureSnapshotWithInteractor: vi.fn(async ({ options }: { options: SnapshotOptions }) => { captured.options.push(options); + await captured.held; const nodes = [ { index: 0, depth: 0, type: 'android.widget.FrameLayout', label: 'Root' }, { @@ -48,15 +52,16 @@ vi.mock('../../../snapshot-interactor-capture.ts', () => ({ afterEach(() => { captured.options.length = 0; + captured.held = undefined; }); test('interaction captures hand flags.snapshotScope to the Android platform and keep its scoped tree', async () => { const sessionStore = makeSessionStore('agent-device-interaction-scope-'); const session = makeAndroidSession('scope'); - sessionStore.set(session.name, session); + const ref = sessionStore.publish(session.name, session); const snapshot = await captureSnapshotForSession( - session, + ref, { snapshotScope: 'panel' }, sessionStore, (flags: CommandFlags | undefined, appBundleId?: string, traceLogPath?: string) => @@ -70,3 +75,48 @@ test('interaction captures hand flags.snapshotScope to the Android platform and 'Save', ]); }); + +for (const change of ['rebuild', 'replace'] as const) { + test(`a held interaction capture respects its scoped lifetime after ${change}`, async () => { + const sessionStore = makeSessionStore(); + const address = 'cwd:interaction-capture:default'; + const ref = sessionStore.publish(address, makeAndroidSession('default')); + let release!: () => void; + captured.held = new Promise((resolve) => { + release = resolve; + }); + const running = captureSnapshotForSession(ref, {}, sessionStore, () => ({}), { + interactiveOnly: true, + }); + const result = running.then( + (snapshot) => ({ snapshot }), + (error: unknown) => ({ error }), + ); + try { + await vi.waitFor(() => expect(captured.options).toHaveLength(1)); + if (change === 'rebuild') { + sessionStore.update(ref, { appName: 'Intervening rebuild' }); + release(); + expect(await result).toHaveProperty('snapshot'); + expect(sessionStore.requireCurrent(ref).appName).toBe('Intervening rebuild'); + expect(sessionStore.requireCurrent(ref).snapshot?.nodes).toHaveLength(4); + } else { + sessionStore.retire(ref); + const successor = sessionStore.publish( + address, + makeAndroidSession('default', { appName: 'Successor' }), + ); + release(); + expect(await result).toMatchObject({ + error: { details: { reason: 'session_lifetime_ended' } }, + }); + expect(sessionStore.requireCurrent(successor)).toBe(successor.session); + expect(successor.session.snapshot).toBeUndefined(); + } + expect(sessionStore.get('default')).toBeUndefined(); + } finally { + release(); + await result; + } + }); +} diff --git a/src/daemon/interaction/internal/find-target-capture.ts b/src/daemon/interaction/internal/find-target-capture.ts index e0b906f539..9160a93e85 100644 --- a/src/daemon/interaction/internal/find-target-capture.ts +++ b/src/daemon/interaction/internal/find-target-capture.ts @@ -5,7 +5,7 @@ import type { CaptureProvenance, RequestCaptureProof } from '../../capture-discl import { createSelectorCaptureRuntime } from '../../selector-capture-runtime.ts'; import { SessionStore } from '../../session-store.ts'; import type { DaemonRequest, DaemonResponse } from '../../daemon-request.ts'; -import type { SessionState } from '../../session-state.ts'; +import type { SessionRef, SessionState } from '../../session-state.ts'; import { errorResponse } from '@agent-device/kernel/contracts'; /** The tree a mutating find resolves its target against, plus what the capture disclosed. */ @@ -19,6 +19,7 @@ export type FindTargetTree = CaptureProvenance & */ export function createFindTargetCapture( params: Readonly<{ + ref: SessionRef; device: SessionState['device']; session: SessionState; req: DaemonRequest; @@ -37,6 +38,7 @@ export function createFindTargetCapture( ): () => Promise { const { device, session, req, logPath, locator, query, sessionStore, sessionName } = params; const captureRuntime = createSelectorCaptureRuntime({ + ref: params.ref, device, session, sessionStore, diff --git a/src/daemon/interaction/internal/find.ts b/src/daemon/interaction/internal/find.ts index 4b25bdfc64..b127ad8e70 100644 --- a/src/daemon/interaction/internal/find.ts +++ b/src/daemon/interaction/internal/find.ts @@ -14,7 +14,7 @@ import { } from '@agent-device/kernel/snapshot'; import { expireRefFrame } from '../../ref-frame.ts'; import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../../daemon-request.ts'; -import type { SessionState } from '../../session-state.ts'; +import type { SessionRef, SessionState } from '../../session-state.ts'; import { SessionStore } from '../../session-store.ts'; import { contextFromFlags } from '../../context.ts'; import { readCommandMessage, successText } from '@agent-device/kernel/success-text'; @@ -44,6 +44,7 @@ type FindContext = { logPath: string; sessionStore: SessionStore; invoke: DaemonInvokeFn; + sessionRef: SessionRef; session: SessionState; device: SessionState['device']; command: string; @@ -75,6 +76,7 @@ type ResolvedMatch = { export async function handleFindCommands(params: FindRouteInput): Promise { const { req, sessionName, logPath, sessionStore, invoke } = params; + const sessionRef = sessionStore.lookup(sessionName); const command = req.command; if (command !== 'find') return null; @@ -106,8 +108,8 @@ export async function handleFindCommands(params: FindRouteInput): Promise { + params = bindInteractionSession(params); return await dispatchGestureInteraction(params, 'gesture', async (session) => runGestureInteraction(params, session), ); @@ -156,6 +158,7 @@ function buildGestureOutcome( export async function dispatchSwipeViaRuntime( params: GestureHandlerParams, ): Promise { + params = bindInteractionSession(params); return await dispatchGestureInteraction(params, 'swipe', async (session) => { const input = readSwipeInput(params.req.input); // One bind for the whole series: `--count N` executes the bound operation N times under a diff --git a/src/daemon/interaction/internal/interaction-ios-tap-outcome.ts b/src/daemon/interaction/internal/interaction-ios-tap-outcome.ts index a9fc2df1b1..977114c8d6 100644 --- a/src/daemon/interaction/internal/interaction-ios-tap-outcome.ts +++ b/src/daemon/interaction/internal/interaction-ios-tap-outcome.ts @@ -11,7 +11,7 @@ import { getRequestSignal } from '@agent-device/host-kit/request'; import { isLocalIosRunnerSession } from '../../direct-ios-selector.ts'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import type { SessionStore } from '../../session-store.ts'; -import type { SessionState } from '../../session-state.ts'; +import type { SessionRef } from '../../session-state.ts'; import type { BoundContextFromFlags, CaptureSnapshotForSession } from './types.ts'; const XCTEST_RECORDED_FAILURE = 'XCTEST_RECORDED_FAILURE'; @@ -35,7 +35,7 @@ export type IosTapCorroborationParams = { command: string; requestId: string | undefined; flags: CommandFlags | undefined; - session: SessionState; + ref: SessionRef; sessionStore: SessionStore; contextFromFlags: BoundContextFromFlags; captureSnapshotForSession: CaptureSnapshotForSession; @@ -55,7 +55,7 @@ export async function corroborateIosTapFailure( params: IosTapCorroborationParams, ): Promise { if (!canCorroborateIosTapFailure(params)) return undefined; - const baseline = readCorroborationBaseline(params.session.snapshot); + const baseline = readCorroborationBaseline(params.ref.session.snapshot); if (!baseline) return undefined; const after = await captureCorroborationSnapshot( @@ -73,7 +73,7 @@ function canCorroborateIosTapFailure(params: IosTapCorroborationParams): boolean return ( isTapCommand(params.command) && asAppError(params.error).code === XCTEST_RECORDED_FAILURE && - isLocalIosRunnerSession(params.session, { skipPendingPostGestureStabilization: false }) + isLocalIosRunnerSession(params.ref.session, { skipPendingPostGestureStabilization: false }) ); } @@ -121,7 +121,7 @@ async function captureCorroborationSnapshot( try { const preferredBackend = preferredSnapshotBackendForVerdict(baselineVerdict); return await params.captureSnapshotForSession( - params.session, + params.ref, matchingCaptureFlags(params.flags, presentation), params.sessionStore, params.contextFromFlags, diff --git a/src/daemon/interaction/internal/interaction-runtime.ts b/src/daemon/interaction/internal/interaction-runtime.ts index 0ca084d4ad..cc146829b1 100644 --- a/src/daemon/interaction/internal/interaction-runtime.ts +++ b/src/daemon/interaction/internal/interaction-runtime.ts @@ -1,3 +1,4 @@ +import { bindInteractionSession } from './interaction-session.ts'; import { publicPlatformString } from '@agent-device/kernel/device'; import { AppError as KernelAppError } from '@agent-device/kernel/errors'; import type { @@ -41,8 +42,9 @@ export function createInteractionRuntimeForRoute( gestures?: BoundGestureExecutor; }, ) { - const session = params.sessionStore.get(params.sessionName); - if (!session) throw new KernelAppError('SESSION_NOT_FOUND', NO_ACTIVE_SESSION_MESSAGE); + const ref = bindInteractionSession(params).sessionRef; + if (!ref) throw new KernelAppError('SESSION_NOT_FOUND', NO_ACTIVE_SESSION_MESSAGE); + const session = params.sessionStore.requireCurrent(ref); return createInteractionAgentDevice({ requestId: params.req.meta?.requestId, flags: params.req.flags, @@ -50,7 +52,7 @@ export function createInteractionRuntimeForRoute( contextFromFlags: params.contextFromFlags, captureSnapshot: async (flags, options) => { const snapshot = await params.captureSnapshotForSession( - session, + ref, flags, params.sessionStore, params.contextFromFlags, @@ -60,18 +62,18 @@ export function createInteractionRuntimeForRoute( }, runtimeSessions: createDaemonRuntimeSessionStore({ sessionName: params.sessionName, - getSession: () => session, + sessionStore: params.sessionStore, + ref, recordOptions: { includeSnapshot: true, omitRefFrameSnapshot: params.req.internal?.findResolvedTarget !== undefined, }, - setRecord: (record) => { + setRecord: (record, current) => { if (!record.snapshot) return; - setSessionSnapshot(session, record.snapshot); - params.sessionStore.set(params.sessionName, session); + setSessionSnapshot(current!, record.snapshot); }, }), - expireRefFrame: () => expireRefFrame(session), + expireRefFrame: () => expireRefFrame(params.sessionStore.requireCurrent(ref)), confirmOffscreenTargetVisible: isLocalIosRunnerSession(session, { skipPendingPostGestureStabilization: false, }) diff --git a/src/daemon/interaction/internal/interaction-session.ts b/src/daemon/interaction/internal/interaction-session.ts new file mode 100644 index 0000000000..fa079cd281 --- /dev/null +++ b/src/daemon/interaction/internal/interaction-session.ts @@ -0,0 +1,12 @@ +import type { InteractionRouteInput } from './types.ts'; +import type { SessionRef } from '../../session-state.ts'; + +export function bindInteractionSession( + params: Params, +): Params & { sessionRef: SessionRef | undefined } { + return { + ...params, + sessionRef: + 'sessionRef' in params ? params.sessionRef : params.sessionStore.lookup(params.sessionName), + }; +} diff --git a/src/daemon/interaction/internal/interaction-touch-android-freshness.ts b/src/daemon/interaction/internal/interaction-touch-android-freshness.ts index 47d0c02b41..2704184b40 100644 --- a/src/daemon/interaction/internal/interaction-touch-android-freshness.ts +++ b/src/daemon/interaction/internal/interaction-touch-android-freshness.ts @@ -20,7 +20,7 @@ export async function refreshAndroidRefSnapshotIfFreshnessActive( session.snapshot?.comparisonSafe === true ? session.snapshot : undefined; try { await params.captureSnapshotForSession( - session, + params.sessionRef!, params.req.flags, params.sessionStore, params.contextFromFlags, diff --git a/src/daemon/interaction/internal/interaction-touch-direct-ios.ts b/src/daemon/interaction/internal/interaction-touch-direct-ios.ts index a6cce12d9f..46835feae8 100644 --- a/src/daemon/interaction/internal/interaction-touch-direct-ios.ts +++ b/src/daemon/interaction/internal/interaction-touch-direct-ios.ts @@ -129,7 +129,7 @@ async function buildDirectIosCorroboratedResponse(params: { command: handlerParams.req.command, requestId: handlerParams.req.meta?.requestId, flags: handlerParams.req.flags, - session, + ref: handlerParams.sessionRef!, sessionStore: handlerParams.sessionStore, contextFromFlags: handlerParams.contextFromFlags, captureSnapshotForSession: handlerParams.captureSnapshotForSession, diff --git a/src/daemon/interaction/internal/interaction-touch-fill.ts b/src/daemon/interaction/internal/interaction-touch-fill.ts index 16dd20e515..5d6523125a 100644 --- a/src/daemon/interaction/internal/interaction-touch-fill.ts +++ b/src/daemon/interaction/internal/interaction-touch-fill.ts @@ -1,9 +1,11 @@ +import { bindInteractionSession } from './interaction-session.ts'; import type { CommandFlags } from '@agent-device/contracts/command'; import type { FillCommandResult, InteractionTarget } from '@agent-device/contracts/interaction'; import { issueSettleRefs, resolveRefStalenessWarning } from '../../session-snapshot.ts'; import { readRefMutationFrame } from '../../ref-frame.ts'; import type { DaemonResponse } from '../../daemon-request.ts'; -import type { SessionState } from '../../session-state.ts'; +import type { SessionRef, SessionState } from '../../session-state.ts'; +import type { SessionStore } from '../../session-store.ts'; import { isSessionRecording } from '../../session-script-publication-capability.ts'; import { assertRecordedFillParameterization } from './interaction-recorded-input.ts'; import { readSettleRequest, settleFlagGuardResponse } from './interaction-flags.ts'; @@ -48,9 +50,10 @@ type AdmittedFill = { }; export async function dispatchFillViaRuntime(params: FillParams): Promise { + params = bindInteractionSession(params); const admission = await admitFill(params); if ('response' in admission) return admission.response; - const { session, parsedTarget, touchExecutor, staleRefsWarning } = admission.admitted; + const { parsedTarget, touchExecutor, staleRefsWarning } = admission.admitted; const { req, sessionName } = params; const replayTargetGuard = req.internal?.replayTargetGuard; @@ -77,7 +80,8 @@ export async function dispatchFillViaRuntime(params: FillParams): Promise buildFillResponsePayloads({ - session, + ref: params.sessionRef!, + sessionStore: params.sessionStore, result, text: parsedTarget.text, flags: req.flags, @@ -198,13 +202,15 @@ async function prepareFillRefTarget( } function buildFillResponsePayloads(params: { - session: SessionState; + ref: SessionRef; + sessionStore: SessionStore; result: FillCommandResult; text: string; flags: CommandFlags | undefined; staleRefsWarning: string | undefined; }): InteractionResponsePayloads { - const { session, result } = params; + const { result, ref, sessionStore } = params; + const session = sessionStore.requireCurrent(ref); const maestroFallback = maestroFallbackDisclosure( params.flags?.maestro?.allowNonHittableCoordinateFallback === true, result.backendResult, @@ -227,6 +233,6 @@ function buildFillResponsePayloads(params: { referenceFrame, extra: { text: params.text, ...maestroFallback.extra }, staleRefsWarning: params.staleRefsWarning, - settleRefsGeneration: issueSettleRefs(session, result.settle), + settleRefsGeneration: issueSettleRefs(ref, sessionStore, result.settle), }); } diff --git a/src/daemon/interaction/internal/interaction-touch-press.ts b/src/daemon/interaction/internal/interaction-touch-press.ts index cd6287edea..ac0b0e396b 100644 --- a/src/daemon/interaction/internal/interaction-touch-press.ts +++ b/src/daemon/interaction/internal/interaction-touch-press.ts @@ -1,3 +1,4 @@ +import { bindInteractionSession } from './interaction-session.ts'; import type { CommandFlags } from '@agent-device/contracts/command'; import type { InteractionTarget, @@ -37,6 +38,7 @@ export async function dispatchTargetedTouchViaRuntime( params: TargetedTouchParams, command: TargetedTouchCommand, ): Promise { + params = bindInteractionSession(params); const admission = await admitTargetedTouch(params, command); if ('response' in admission) return admission.response; const { admitted } = admission; diff --git a/src/daemon/interaction/internal/interaction-touch-reference-frame.ts b/src/daemon/interaction/internal/interaction-touch-reference-frame.ts index 0020fdb596..ecf4a54b22 100644 --- a/src/daemon/interaction/internal/interaction-touch-reference-frame.ts +++ b/src/daemon/interaction/internal/interaction-touch-reference-frame.ts @@ -4,20 +4,21 @@ import type { GestureReferenceFrame } from '@agent-device/contracts/scroll-gestu import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import type { SessionStore } from '../../session-store.ts'; import { getSnapshotReferenceFrame } from '@agent-device/capture-kit/touch-reference-frame'; -import type { SessionState } from '../../session-state.ts'; +import type { SessionRef } from '../../session-state.ts'; import type { BoundContextFromFlags, CaptureSnapshotForSession } from './types.ts'; import { isActiveProviderDevice } from '../../provider-device-admission.ts'; async function resolveDirectTouchReferenceFrame(params: { - session: SessionState; + ref: SessionRef; flags: CommandFlags | undefined; sessionStore: SessionStore; contextFromFlags: BoundContextFromFlags; captureSnapshotForSession: CaptureSnapshotForSession; observation?: AndroidObservationAdapter; }): Promise { - const { session, flags, sessionStore, contextFromFlags, captureSnapshotForSession, observation } = + const { ref, flags, sessionStore, contextFromFlags, captureSnapshotForSession, observation } = params; + const session = sessionStore.requireCurrent(ref); const recording = session.screenRecording?.handle; if (!recording) { return undefined; @@ -48,7 +49,7 @@ async function resolveDirectTouchReferenceFrame(params: { return snapshotFrame; } - const snapshot = await captureSnapshotForSession(session, flags, sessionStore, contextFromFlags, { + const snapshot = await captureSnapshotForSession(ref, flags, sessionStore, contextFromFlags, { interactiveOnly: true, }); const referenceFrame = getSnapshotReferenceFrame(snapshot); @@ -57,7 +58,7 @@ async function resolveDirectTouchReferenceFrame(params: { } export async function resolveDirectTouchReferenceFrameSafely(params: { - session: SessionState; + ref: SessionRef; flags: CommandFlags | undefined; sessionStore: SessionStore; contextFromFlags: BoundContextFromFlags; @@ -71,7 +72,7 @@ export async function resolveDirectTouchReferenceFrameSafely(params: { level: 'warn', phase: 'touch_reference_frame_resolve_failed', data: { - platform: params.session.device.platform, + platform: params.ref.session.device.platform, error: error instanceof Error ? error.message : String(error), }, }); diff --git a/src/daemon/interaction/internal/interaction-touch-response.ts b/src/daemon/interaction/internal/interaction-touch-response.ts index 116162a8cc..e7235e216e 100644 --- a/src/daemon/interaction/internal/interaction-touch-response.ts +++ b/src/daemon/interaction/internal/interaction-touch-response.ts @@ -294,24 +294,31 @@ export async function buildTargetedTouchResponsePayloads(params: { publicData?: Record; extra: Record; }): Promise { - const { params: handlerParams, session, result, publicData, extra } = params; + const { params: handlerParams, result, publicData, extra } = params; const referenceFrame = result.kind === 'point' ? await resolveDirectTouchReferenceFrameSafely({ - session, + ref: handlerParams.sessionRef!, flags: handlerParams.req.flags, sessionStore: handlerParams.sessionStore, contextFromFlags: handlerParams.contextFromFlags, captureSnapshotForSession: handlerParams.captureSnapshotForSession, observation: handlerParams.androidObservation, }) - : readSnapshotNodesReferenceFrame(session.snapshot?.nodes ?? []); + : readSnapshotNodesReferenceFrame( + handlerParams.sessionStore.requireCurrent(handlerParams.sessionRef!).snapshot?.nodes ?? + [], + ); return buildInteractionResponseData({ source: { kind: 'runtime', result, publicData }, referenceFrame, extra, staleRefsWarning: params.staleRefsWarning, - settleRefsGeneration: issueSettleRefs(session, result.settle), + settleRefsGeneration: issueSettleRefs( + handlerParams.sessionRef, + handlerParams.sessionStore, + result.settle, + ), }); } diff --git a/src/daemon/interaction/internal/interaction-touch-runtime.ts b/src/daemon/interaction/internal/interaction-touch-runtime.ts index 6bbc473006..aa7f4c400a 100644 --- a/src/daemon/interaction/internal/interaction-touch-runtime.ts +++ b/src/daemon/interaction/internal/interaction-touch-runtime.ts @@ -1,3 +1,4 @@ +import { bindInteractionSession } from './interaction-session.ts'; import type { FillCommandResult, InteractionTarget, @@ -67,8 +68,9 @@ export async function dispatchRuntimeInteraction< ): InteractionResponsePayloads | Promise; }, ): Promise { - const session = params.sessionStore.get(params.sessionName); - if (!session) return noActiveSessionError(); + params = bindInteractionSession(params); + if (!params.sessionRef) return noActiveSessionError(); + const session = params.sessionStore.requireCurrent(params.sessionRef); const runtime = createInteractionRuntimeForRoute({ ...params, touchExecutor: options.touchExecutor, @@ -159,7 +161,7 @@ async function buildRuntimeIosCorroboratedResponse(params: { command: params.handlerParams.req.command, requestId: params.handlerParams.req.meta?.requestId, flags: params.handlerParams.req.flags, - session: params.session, + ref: params.handlerParams.sessionRef!, sessionStore: params.handlerParams.sessionStore, contextFromFlags: params.handlerParams.contextFromFlags, captureSnapshotForSession: params.handlerParams.captureSnapshotForSession, diff --git a/src/daemon/interaction/internal/interaction.ts b/src/daemon/interaction/internal/interaction.ts index 1cc82a0a63..9906d71224 100644 --- a/src/daemon/interaction/internal/interaction.ts +++ b/src/daemon/interaction/internal/interaction.ts @@ -1,5 +1,5 @@ import type { DaemonResponse } from '../../daemon-request.ts'; -import type { SessionState } from '../../session-state.ts'; +import type { SessionRef, SessionState } from '../../session-state.ts'; import { type RequestCaptureProof, withCaptureDisclosures } from '../../capture-disclosure.ts'; import type { CaptureSnapshotForSession, InteractionRouteInput } from './types.ts'; import { dispatchFillViaRuntime } from './interaction-touch-fill.ts'; @@ -22,10 +22,14 @@ import { import { errorResponse, noActiveSessionError } from '@agent-device/kernel/contracts'; export async function handleInteractionCommands( - params: InteractionRouteInput & { captureSnapshotForSession: CaptureSnapshotForSession }, + params: InteractionRouteInput & { + sessionRef: SessionRef | undefined; + captureSnapshotForSession: CaptureSnapshotForSession; + }, ): Promise { const captureProof: RequestCaptureProof = {}; - const routed = { ...params, refSnapshotFlagGuardResponse, captureProof }; + const sessionRef = params.sessionRef; + const routed = { ...params, sessionRef, refSnapshotFlagGuardResponse, captureProof }; const response = await dispatchInteractionCommand(routed); return response ? withCaptureDisclosures({ response, consumedTree: captureProof, captureProof }) diff --git a/src/daemon/interaction/internal/types.ts b/src/daemon/interaction/internal/types.ts index 9236285c0d..9d50adc10b 100644 --- a/src/daemon/interaction/internal/types.ts +++ b/src/daemon/interaction/internal/types.ts @@ -8,7 +8,7 @@ import type { DeferredInteractionOutcomeMark } from '../../deferred-interaction- import type { RecordActionEntry } from '../../session-action-recorder.ts'; import type { BoundContextFromFlags } from '../../context.ts'; import type { DaemonInvokeFn, DaemonRequest, DaemonResponse } from '../../daemon-request.ts'; -import type { SessionState } from '../../session-state.ts'; +import type { SessionRef, SessionState } from '../../session-state.ts'; import type { BoundGestureExecutor } from '../../gesture-runtime.ts'; import type { BoundTouchExecutor } from '../../touch-runtime.ts'; import type { BoundSnapshotCapture } from '../../snapshot-runtime-binding.ts'; @@ -26,6 +26,7 @@ export type InteractionRouteInput = { sessionName: string; logPath?: string; sessionStore: SessionStore; + sessionRef?: SessionRef; captureSnapshotForSession?: CaptureSnapshotForSession; contextFromFlags: BoundContextFromFlags; inspectFacts?: InspectDeviceRuntimeFacts; @@ -49,7 +50,7 @@ export type FindRouteInput = { }; export type CaptureSnapshotForSession = ( - session: SessionState, + ref: SessionRef, flags: CommandFlags | undefined, sessionStore: SessionStore, contextFromFlags: BoundContextFromFlags, diff --git a/src/daemon/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/request-generic-dispatch.ts b/src/daemon/request-generic-dispatch.ts index 7f82f464d3..a3eaccfafe 100644 --- a/src/daemon/request-generic-dispatch.ts +++ b/src/daemon/request-generic-dispatch.ts @@ -4,7 +4,7 @@ import { commandSupportsSettleObservation } from '@agent-device/command-registry import type { SessionStore } from './session-store.ts'; import type { DaemonCommandContext } from './context.ts'; import type { DaemonRequest, DaemonResponse } from './daemon-request.ts'; -import type { SessionState } from './session-state.ts'; +import type { SessionRef, SessionState } from './session-state.ts'; import { ensureAndroidBlockingSystemDialogReady, recoverAndroidBlockingSystemDialog, @@ -75,6 +75,7 @@ export async function dispatchGenericCommand(params: { androidObservation?: AndroidObservationAdapter; }): Promise { const { req, session, logPath, sessionStore, contextFromFlags } = params; + const sessionRef = sessionStore.lookup(params.sessionName); const platformCommand = req.command; const commandReadiness = await ensureGenericCommandReady( @@ -86,6 +87,7 @@ export async function dispatchGenericCommand(params: { // #1638: freeze the settled diff's baseline before anything can mutate the // screen or the stored snapshot — including the Android dialog preflight. const settlePlan = await planGenericSettleObservation({ + sessionRef, req, session, sessionName: params.sessionName, @@ -234,6 +236,7 @@ function withReadinessWarnings( * without settle, and every non-settle generic leaf, load nothing. */ async function planGenericSettleObservation(params: { + sessionRef: SessionRef | undefined; req: DaemonRequest; session: SessionState; sessionName: string; diff --git a/src/daemon/runtime-session.ts b/src/daemon/runtime-session.ts index 372c731ba3..f781c49fd3 100644 --- a/src/daemon/runtime-session.ts +++ b/src/daemon/runtime-session.ts @@ -1,7 +1,8 @@ import type { CommandSessionRecord, CommandSessionStore } from '../runtime-contract.ts'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { refFrameTree } from './ref-frame.ts'; -import type { SessionState } from './session-state.ts'; +import type { SessionRef, SessionState } from './session-state.ts'; +import type { SessionStore } from './session-store.ts'; export type RuntimeSessionRecordOptions = { includeSnapshot?: boolean; @@ -44,16 +45,38 @@ function toRuntimeSessionRecord( }; } +export function createReadonlyRuntimeSessionStore( + sessionName: string, + session: SessionState, +): CommandSessionStore { + return { + get: (name) => + name === sessionName ? toRuntimeSessionRecord(session, sessionName) : undefined, + set: () => {}, + }; +} + export function createDaemonRuntimeSessionStore(params: { sessionName: string; - getSession: () => SessionState | undefined; + sessionStore: SessionStore; + ref: SessionRef | undefined; recordOptions?: RuntimeSessionRecordOptions; - setRecord: (record: CommandSessionRecord) => void; -}): CommandSessionStore { + setRecord: ( + record: CommandSessionRecord, + current: SessionState | undefined, + ref: SessionRef | undefined, + ) => SessionRef | void; +}): CommandSessionStore & { getRef(): SessionRef | undefined } { + let ref = params.ref; return { + getRef: () => (ref ? params.sessionStore.refresh(ref) : undefined), get: (name) => name === params.sessionName - ? toRuntimeSessionRecord(params.getSession(), params.sessionName, params.recordOptions) + ? toRuntimeSessionRecord( + ref ? params.sessionStore.resolveCurrent(ref) : undefined, + params.sessionName, + params.recordOptions, + ) : undefined, set: (record) => { if (record.name !== params.sessionName) { @@ -64,7 +87,9 @@ export function createDaemonRuntimeSessionStore(params: { }); return; } - params.setRecord(record); + const current = ref ? params.sessionStore.requireCurrent(ref) : undefined; + const published = params.setRecord(record, current, ref); + if (published) ref = published; }, }; } diff --git a/src/daemon/screen-recording-session-binding.ts b/src/daemon/screen-recording-session-binding.ts new file mode 100644 index 0000000000..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/screenshot-runtime.ts b/src/daemon/screenshot-runtime.ts index f0e4c76477..b07e829607 100644 --- a/src/daemon/screenshot-runtime.ts +++ b/src/daemon/screenshot-runtime.ts @@ -1,3 +1,4 @@ +import { expandSessionPath } from '@agent-device/host-kit/session-paths'; import type { CommandFlags } from '@agent-device/contracts/command'; import { retiredScreenshotMaxSizeFlagError, @@ -28,7 +29,7 @@ import type { RecordedGenericRequest, ResolvedGenericExecution, } from './request-generic-dispatch.ts'; -import { createDaemonRuntimeSessionStore } from './runtime-session.ts'; +import { createReadonlyRuntimeSessionStore } from './runtime-session.ts'; import { assertScreenshotCropPolicy } from './screenshot-crop-target.ts'; import { buildScreenshotCropWarnings, cropScreenshotToSelector } from './screenshot-crop.ts'; import { annotateScreenshotWithRefs } from '@agent-device/capture-kit/screenshot-overlay'; @@ -38,7 +39,6 @@ import { type ScreenshotRuntimeBindings, } from './screenshot-runtime-binding.ts'; import { setSessionSnapshot } from './session-snapshot.ts'; -import { SessionStore } from './session-store.ts'; import type { DaemonRequest } from './daemon-request.ts'; import type { SessionState } from './session-state.ts'; @@ -121,12 +121,7 @@ export async function captureScreenshotArtifact( const runtime = createCommandSurfaceAgentDevice({ backend: createBoundScreenshotBackend(params), artifacts: createDaemonScreenshotArtifactAdapter(), - sessions: createDaemonRuntimeSessionStore({ - sessionName, - getSession: () => session, - recordOptions: { includeSnapshot: false }, - setRecord: () => {}, - }), + sessions: createReadonlyRuntimeSessionStore(sessionName, session), policy: localCommandPolicy(), }); @@ -321,7 +316,7 @@ function readScreenshotRequest( const positionals = req.positionals ?? []; const flags = req.flags ?? {}; const expand = (value: string | undefined) => - value === undefined ? undefined : SessionStore.expandHome(value, req.meta?.cwd); + value === undefined ? undefined : expandSessionPath(value, req.meta?.cwd); const positionalPath = expand(positionals[0]); const outFlag = expand(flags.out); return { diff --git a/src/daemon/selector-capture-runtime.ts b/src/daemon/selector-capture-runtime.ts index 18a2664970..2cfd92c54d 100644 --- a/src/daemon/selector-capture-runtime.ts +++ b/src/daemon/selector-capture-runtime.ts @@ -8,7 +8,7 @@ import { } from '@agent-device/kernel/snapshot'; import { isSparseSnapshotQualityVerdict } from '@agent-device/capture-kit/snapshot-quality-verdict'; import type { DaemonRequest } from './daemon-request.ts'; -import type { SessionState } from './session-state.ts'; +import type { SessionRef, SessionState } from './session-state.ts'; import { SessionStore } from './session-store.ts'; import { recordCaptureProof } from './capture-disclosure.ts'; import type { RequestCaptureProof } from './capture-disclosure.ts'; @@ -24,6 +24,7 @@ import { isLegacySparseIosInteractiveSnapshot } from '@agent-device/selectors/ab const SELECTOR_CAPTURE_CACHE_TTL_MS = 750; export type SelectorCaptureRuntimeParams = { + ref: SessionRef | undefined; device: SessionState['device']; session: SessionState | undefined; sessionStore: SessionStore; @@ -81,7 +82,7 @@ type SelectorCaptureRequest = { type SelectorCaptureResult = BackendSnapshotResult & { snapshot: SnapshotState }; export function createSelectorCaptureRuntime(params: SelectorCaptureRuntimeParams) { - const { session, sessionStore, sessionName } = params; + const { sessionStore, ref } = params; let lastSnapshotAt = 0; let lastSnapshotResult: SelectorCaptureResult | undefined; let lastSnapshotCacheKey: string | undefined; @@ -92,6 +93,7 @@ export function createSelectorCaptureRuntime(params: SelectorCaptureRuntimeParam }; const capture = async (request: SelectorCaptureRequest): Promise => { + const session = ref ? sessionStore.requireCurrent(ref) : undefined; const timestamp = Date.now(); const cacheKey = selectorCaptureCacheKey(request, params.req.flags?.out); const reusableLastSnapshot = readReusableLastSnapshot({ @@ -118,7 +120,7 @@ export function createSelectorCaptureRuntime(params: SelectorCaptureRuntimeParam const snapshot = await captureSelectorSnapshot({ params, request }); request.signal?.throwIfAborted(); const result = { snapshot }; - updateSessionSnapshot({ session, sessionStore, sessionName, snapshot }); + updateSessionSnapshot({ ref, sessionStore, snapshot }); lastSnapshotAt = timestamp; lastSnapshotResult = result; lastSnapshotCacheKey = cacheKey; @@ -324,13 +326,11 @@ function flagsForPresentation(request: SelectorCaptureRequest): CommandFlags | u } function updateSessionSnapshot(params: { - session: SessionState | undefined; + ref: SessionRef | undefined; sessionStore: SessionStore; - sessionName: string; snapshot: SnapshotState; }): void { - const { session, sessionStore, sessionName, snapshot } = params; - if (!session || isSparseSnapshotQualityVerdict(snapshot.snapshotQuality)) return; - setSessionSnapshot(session, snapshot); - sessionStore.set(sessionName, session); + const { ref, sessionStore, snapshot } = params; + if (!ref || isSparseSnapshotQualityVerdict(snapshot.snapshotQuality)) return; + setSessionSnapshot(sessionStore.requireCurrent(ref), snapshot); } diff --git a/src/daemon/selector-runtime-backend.test.ts b/src/daemon/selector-runtime-backend.test.ts index e17551b3f4..977f32607a 100644 --- a/src/daemon/selector-runtime-backend.test.ts +++ b/src/daemon/selector-runtime-backend.test.ts @@ -52,6 +52,7 @@ test('wait text passes its poll deadline signal to the Apple runner fast path', }), ); const runtime = createSelectorRuntimeForDevice({ + ref: sessionStore.lookup(sessionName), req: { token: 't', session: sessionName, @@ -112,6 +113,7 @@ test('daemon wait stable pins private-ax on emitted snapshot runner requests', a }, })); const runtime = createSelectorRuntimeForDevice({ + ref: sessionStore.lookup(sessionName), req: { token: 't', session: sessionName, diff --git a/src/daemon/selector-runtime-backend.ts b/src/daemon/selector-runtime-backend.ts index 4af5d63305..1e87c6c47f 100644 --- a/src/daemon/selector-runtime-backend.ts +++ b/src/daemon/selector-runtime-backend.ts @@ -15,7 +15,7 @@ import { setSessionSnapshot } from './session-snapshot.ts'; import { markSessionSnapshotOutdated } from './ref-frame.ts'; import { SessionStore } from './session-store.ts'; import type { DaemonRequest, DaemonResponse } from './daemon-request.ts'; -import type { SessionState } from './session-state.ts'; +import type { SessionRef, SessionState } from './session-state.ts'; import { createSelectorCaptureRuntime } from './selector-capture-runtime.ts'; import { buildRuntimeCaptureInput } from './snapshot-runtime-capture-input.ts'; import { @@ -51,6 +51,7 @@ export type SelectorRuntimeParams = { }; export type SelectorRuntimeDeviceParams = SelectorRuntimeParams & { + ref: SessionRef | undefined; session: SessionState | undefined; device: SessionState['device']; /** @@ -63,11 +64,20 @@ export type SelectorRuntimeDeviceParams = SelectorRuntimeParams & { }; type ResolvedSelectorRuntime = - | { ok: true; runtime: ReturnType } + | { + ok: true; + ref: SessionRef | undefined; + runtime: ReturnType; + } | { ok: false; response: DaemonResponse }; type ResolvedSelectorDevice = - | { ok: true; session: SessionState | undefined; device: SessionState['device'] } + | { + ok: true; + ref: SessionRef | undefined; + session: SessionState | undefined; + device: SessionState['device']; + } | { ok: false; response: DaemonResponse }; export function createSelectorRuntimeForDevice(params: SelectorRuntimeDeviceParams) { @@ -76,12 +86,12 @@ export function createSelectorRuntimeForDevice(params: SelectorRuntimeDevicePara ...createDaemonRuntimePolicy('selector commands', { plural: true }), sessions: createDaemonRuntimeSessionStore({ sessionName: params.sessionName, - getSession: () => params.session, + sessionStore: params.sessionStore, + ref: params.ref, recordOptions: { includeSnapshot: true }, - setRecord: (record) => { - if (!params.session || !record.snapshot) return; - setSessionSnapshot(params.session, record.snapshot); - params.sessionStore.set(params.sessionName, params.session); + setRecord: (record, current) => { + if (!current || !record.snapshot) return; + setSessionSnapshot(current, record.snapshot); }, }), signal: params.signal ?? getRequestSignal(params.req.meta?.requestId), @@ -95,10 +105,11 @@ async function resolveSelectorRuntimeDevice( ): Promise { params.consumedSnapshot ??= {}; params.captureProof ??= {}; - const session = params.sessionStore.get(params.sessionName); + const ref = params.sessionStore.lookup(params.sessionName); + const session = ref?.session; if (!session && requireSession) return { ok: false, response: noActiveSessionError() }; const device = session?.device ?? (await resolveTargetDevice(params.req.flags ?? {})); - return { ok: true, session, device }; + return { ok: true, ref, session, device }; } /** @@ -130,8 +141,10 @@ export async function createBoundSelectorRuntime( if (!bound.ok) return { ok: false, response: bound.response }; return { ok: true, + ref: resolved.ref, runtime: createSelectorRuntimeForDevice({ ...params, + ref: resolved.ref, session: resolved.session, device: resolved.device, bound: bound.operations, @@ -166,6 +179,7 @@ function createSelectorBackend(params: SelectorRuntimeDeviceParams): AgentDevice boundOperations === undefined ? undefined : createSelectorCaptureRuntime({ + ref: params.ref, device, session, sessionStore, diff --git a/src/daemon/selector-runtime.ts b/src/daemon/selector-runtime.ts index 13ea73daa6..4ad7685b04 100644 --- a/src/daemon/selector-runtime.ts +++ b/src/daemon/selector-runtime.ts @@ -85,13 +85,13 @@ export async function dispatchFindReadOnlyViaRuntime( .filter((ref): ref is string => typeof ref === 'string') : []; if (publishedRefs.length > 0) { - const session = params.sessionStore.get(params.sessionName); - if (session) { + const ref = resolvedRuntime.ref; + if (ref) { + const session = params.sessionStore.requireCurrent(ref); // ADR 0014: a read-only find publishes exactly the refs it returned — // one for single-match actions, every listed ref for `list` — so it // activates a PARTIAL frame authorizing exactly those ref bodies. markSessionPartialRefsIssued(session, publishedRefs); - params.sessionStore.set(params.sessionName, session); if (session.snapshotGeneration !== undefined) { return { ...data, refsGeneration: session.snapshotGeneration }; } diff --git a/src/daemon/server/daemon-runtime-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-snapshot-shutdown.test.ts b/src/daemon/server/daemon-runtime-snapshot-shutdown.test.ts new file mode 100644 index 0000000000..d5e0193395 --- /dev/null +++ b/src/daemon/server/daemon-runtime-snapshot-shutdown.test.ts @@ -0,0 +1,125 @@ +import { afterEach, expect, test, vi } from 'vitest'; +import { + clearRequestAbortRegistration, + markRequestCanceled, + registerRequestAbort, +} from '@agent-device/host-kit/request'; +import { ANDROID_EMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; +import { makeAndroidSession } from '../../__tests__/test-utils/session-factories.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { legacyDispatchCapture } from '../__tests__/legacy-snapshot-capture-fixture.ts'; +import { snapshotRuntimeFixture } from '../__tests__/snapshot-runtime-fixture.ts'; +import { platformResourceCleanup } from '../../platform-runtime-resource-cleanup.ts'; +import { DAEMON_SESSION_TEARDOWN_TIMEOUT_MS } from '../session-teardown-budget.ts'; +import { dispatchSnapshotViaRuntime } from '../snapshot-runtime.ts'; +import { teardownDaemonSessionForShutdown } from './daemon-runtime.ts'; + +vi.mock('@agent-device/device-selection/dispatch-resolve', () => ({ + resolveTargetDevice: vi.fn(async () => ANDROID_EMULATOR), +})); +vi.mock('../device/device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) })); +vi.mock('../../platform-runtime-resource-cleanup.ts', () => ({ + platformResourceCleanup: { + stopSnapshotHelper: vi.fn(), + closeManagedBrowser: vi.fn(async () => {}), + cleanupSessionlessExecutionHost: vi.fn(async () => {}), + retainExecutionHostAfterClose: vi.fn(() => false), + }, +})); + +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + legacyDispatchCapture.mockReset(); +}); + +for (const initialSession of ['published', 'draft'] as const) { + test(`bounded shutdown refuses a late ${initialSession} snapshot`, async () => { + const sessionStore = makeSessionStore(); + const address = 'cwd:shutdown-capture:default'; + const shutdownRef = sessionStore.publish( + initialSession === 'published' ? address : 'shutdown-owner', + makeAndroidSession('default'), + ); + const captureEntered = deferred(); + const releaseCapture = deferred(); + const cleanupEntered = deferred(); + const releaseCleanup = deferred(); + const registration = registerRequestAbort(`shutdown-snapshot-${initialSession}`)!; + legacyDispatchCapture.mockImplementation(async () => { + captureEntered.resolve(); + await releaseCapture.promise; + return { nodes: [], truncated: false, backend: 'uiautomator' }; + }); + vi.mocked(platformResourceCleanup.stopSnapshotHelper).mockImplementation(async () => { + cleanupEntered.resolve(); + await releaseCleanup.promise; + }); + const running = dispatchSnapshotViaRuntime({ + req: { + command: 'snapshot', + positionals: [], + token: 'test', + session: address, + meta: { requestId: registration.requestId }, + }, + sessionName: address, + logPath: '/dev/null', + sessionStore, + ...snapshotRuntimeFixture(registration.requestId), + platformResourceCleanup, + }); + const result = running.then( + (response) => ({ response }), + (error: unknown) => ({ error }), + ); + const stderr: string[] = []; + let teardown: Promise | undefined; + try { + await captureEntered.promise; + vi.useFakeTimers(); + sessionStore.closeAdmission(); + markRequestCanceled(registration.requestId); + teardown = teardownDaemonSessionForShutdown({ + ref: shutdownRef, + sessionStore, + stderr: { write: (chunk) => stderr.push(chunk) }, + }); + await cleanupEntered.promise; + await vi.advanceTimersByTimeAsync(DAEMON_SESSION_TEARDOWN_TIMEOUT_MS); + await teardown; + expect(stderr.join('')).toContain('Daemon session teardown timed out (default).'); + expect(sessionStore.resolveCurrent(shutdownRef)).toBeUndefined(); + expect(registration.controller.signal.aborted).toBe(true); + releaseCapture.resolve(); + expect(await result).toMatchObject({ + error: { + details: { + reason: + initialSession === 'published' ? 'session_lifetime_ended' : 'daemon_shutting_down', + }, + }, + }); + expect(sessionStore.lookup(address)).toBeUndefined(); + expect(sessionStore.lookup('default')).toBeUndefined(); + expect(platformResourceCleanup.stopSnapshotHelper).toHaveBeenCalledExactlyOnceWith( + ANDROID_EMULATOR, + ); + } finally { + releaseCapture.resolve(); + releaseCleanup.resolve(); + await result; + await teardown; + clearRequestAbortRegistration(registration); + vi.useRealTimers(); + } + }); +} diff --git a/src/daemon/server/daemon-runtime-web-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 e561d56290..13aa4c346a 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, @@ -199,10 +198,10 @@ export async function teardownDaemonSessionForShutdown(params: { // ADR 0012 decision 6, R7 + commit semantics (C2/C5a): commit the healed // `.ad` iff the repair transaction completed, else leave a bounded // `REPAIR_SESSION_EXPIRED` tombstone for the reaped-before-finalize case. - sessionStore.finalizeRepairTeardown(session); + sessionStore.finalizeRepairTeardown(ref); await beforeDelete?.(session); if (teardownSucceeded) await afterSuccessfulTeardown?.(session); - sessionStore.delete(sessionName); + sessionStore.retire(ref); } export type DaemonRuntimeOptions = { @@ -457,10 +456,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) => @@ -487,7 +487,7 @@ export async function startDaemonRuntime( }; const teardownDaemonSessions = async (): Promise => { - const sessionsToStop = sessionStore.toArray(); + const sessionsToStop = sessionStore.listRefs(); await Promise.all(sessionsToStop.map(teardownDaemonSession)); }; @@ -499,14 +499,13 @@ export async function startDaemonRuntime( // survive so the next pass can retry rather than leave a claim owned by a process that no longer // knows what it holds. So: resources, then the platform finalization that stops the execution host // and releases its lease, then the claim, cleared last, by the reaper. - const settleIdleExpiredSession = async ( - session: SessionState, - sessionName: string, - ): Promise => { + const settleIdleExpiredSession = async (ref: SessionRef): Promise => { + const session = sessionStore.resolveCurrent(ref) ?? ref.session; + const sessionName = ref.address; + const runtimeHints = runtimeHintValues(sessionStore.getRuntimeHints(sessionName)); await teardownSessionResources({ appLog: 'run', - session, - sessionName, + ref, sessionStore, stateDir: baseDir, platformCleanup: platformResourceCleanup, @@ -516,7 +515,7 @@ export async function startDaemonRuntime( scope: createDaemonRecoveryPlatformScope(), session, stateDir: baseDir, - runtimeHints: runtimeHintValues(sessionStore.getRuntimeHints(sessionName)), + runtimeHints, // The one caller that must say so: this daemon is staying alive, so there is no shutdown // phase a healthy runner could be deferred to. Taking the ordinary-close path stops the // runner and releases its lease instead of parking it until process exit. @@ -754,6 +753,7 @@ export async function startDaemonRuntime( sessionIdleExpiry.cancel(); if (shuttingDown) return; shuttingDown = true; + sessionStore.closeAdmission(); stopMetadataLossWatch(); if (shutdownOptions.cause) { await emitFatalDiagnostic(shutdownOptions.cause); diff --git a/src/daemon/server/daemon-session-idle-expiry-scheduling.test.ts b/src/daemon/server/daemon-session-idle-expiry-scheduling.test.ts index 8aae5cd14b..4d1fa1051a 100644 --- a/src/daemon/server/daemon-session-idle-expiry-scheduling.test.ts +++ b/src/daemon/server/daemon-session-idle-expiry-scheduling.test.ts @@ -47,7 +47,7 @@ test('a session still inside its window survives a sweep that actually ran', asy sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), - settleSession: async (_session, sessionName) => { + settleSession: async ({ address: sessionName }) => { settledNames.push(sessionName); }, withinDiagnosticsScope: sweeps.withinDiagnosticsScope, @@ -300,7 +300,7 @@ test('a session that moved to another device is fenced by that device, not the s sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: locks, - settleSession: async (session) => { + settleSession: async ({ session }) => { settledDeviceIds.push(session.device.id); }, withinDiagnosticsScope: sweeps.withinDiagnosticsScope, @@ -479,7 +479,7 @@ test('a daemon beginning to leave does not start settling the next session', asy sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), - settleSession: async (_session, sessionName) => { + settleSession: async ({ address: sessionName }) => { settledNames.push(sessionName); if (sessionName === 'first') await firstHeld; }, diff --git a/src/daemon/server/daemon-session-idle-expiry.test.ts b/src/daemon/server/daemon-session-idle-expiry.test.ts index 402da7d219..b27d64b898 100644 --- a/src/daemon/server/daemon-session-idle-expiry.test.ts +++ b/src/daemon/server/daemon-session-idle-expiry.test.ts @@ -197,8 +197,8 @@ test('a session shutdown finalized mid-settle is not also reported as expired', executionLocks: new Map(), // Shutdown takes no execution lock: it tears the whole session set down directly, so it is the // one remover that can finish while a settle is still running. - settleSession: async (_session, sessionName) => { - sessionStore.delete(sessionName); + settleSession: async (ref) => { + sessionStore.retire(ref); }, withinDiagnosticsScope: sweeps.withinDiagnosticsScope, now: () => NOW, @@ -267,7 +267,7 @@ test('a settled session of another kind is never touched by the sweep', async () executionLocks: new Map(), // Succeeds rather than throwing: a rejected settle leaves every record standing for retry, which // would let this pass read as a correct refusal even if the sweep had torn both sessions down. - settleSession: async (_session, sessionName) => { + settleSession: async ({ address: sessionName }) => { settledNames.push(sessionName); }, now: () => NOW, @@ -376,3 +376,49 @@ test('a committed expiry finalizes the repair transaction it ends', async () => 'committed', ); }); + +test('an idle settle cannot finalize or retire a replacement at its scoped address', async () => { + const { sessionStore } = makeFixture('agent-device-idle-expiry-lifetime-'); + idleClaimedSession(sessionStore); + const old = sessionStore.lookup('default')!; + sessionStore.retire(old); + const address = 'cwd:idle:default'; + const ref = sessionStore.publish(address, old.session); + const sweeps = createSweepBarrier(); + let release!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + let entered = false; + const controller = createSessionIdleExpiry({ + sessionStore, + idleExpiryMs: WINDOW_MS, + executionLocks: new Map(), + settleSession: async () => { + entered = true; + await held; + }, + withinDiagnosticsScope: sweeps.withinDiagnosticsScope, + now: () => NOW, + }); + try { + controller.noteSessionsChanged(); + await waitFor(() => entered, 'the held idle settle'); + sessionStore.retire(ref); + const successor = sessionStore.publish(address, makeRepairCompleteSession('default')); + sessionStore.setRuntimeHints(address, { metroPort: 9090 }); + release(); + await sweeps.swept(); + assert.equal(sessionStore.requireCurrent(successor), successor.session); + assert.equal(successor.session.scriptPublication?.kind, 'repair'); + if (successor.session.scriptPublication?.kind === 'repair') { + assert.equal(successor.session.scriptPublication.status, 'complete'); + } + assert.equal(successor.session.actions.length, 0); + assert.equal(sessionStore.getRuntimeHints(address)?.metroPort, 9090); + assert.equal(sessionStore.readIdleExpiryTombstone(address), undefined); + } finally { + release(); + controller.cancel(); + } +}); diff --git a/src/daemon/server/daemon-session-idle-expiry.ts b/src/daemon/server/daemon-session-idle-expiry.ts index 0ffd0f33bf..158f63873b 100644 --- a/src/daemon/server/daemon-session-idle-expiry.ts +++ b/src/daemon/server/daemon-session-idle-expiry.ts @@ -11,11 +11,11 @@ import { sessionIdleDeadlineMs, type SessionIdleExpiryOutcome, } from '../session-idle-expiry.ts'; -import type { SessionState } from '../session-state.ts'; +import type { SessionRef, SessionState } from '../session-state.ts'; import type { SessionStore } from '../session-store.ts'; /** Settles one expired session's owned resources. Supplied by the runtime, which owns the seams. */ -export type IdleSessionSettler = (session: SessionState, sessionName: string) => Promise; +export type IdleSessionSettler = (ref: SessionRef) => Promise; /** The one outcome this reaper invents: the clear threw, so nothing about the claim is known. */ const CLAIM_CLEAR_FAILED = 'claim-clear-failed'; @@ -105,7 +105,7 @@ export function createSessionIdleExpiry(params: { // without taking any execution lock, so it is the one remover that can finalize a session out from // under a settle. A sweep already inside a settle cannot be recalled, and settles there because // stopping mid-teardown would strand a resource; what it must not do is write an idle-expiry - // marker over a shutdown's close, which `SessionStore.delete`'s answer detects. + // marker over a shutdown's close; retiring the captured lifetime detects that case. let closing = false; // Addresses with a settle in flight. A settle whose budget expired stopped being WAITED on, not // stopped: it keeps holding the session's execution lock until it actually finishes. Without this @@ -254,7 +254,7 @@ async function expireIdleSessions(params: IdleExpirySweepParams): Promise * that lands after the budget still counts. */ async function expireIdleSession( - params: IdleExpirySweepParams & { ref: { address: string; session: SessionState } }, + params: IdleExpirySweepParams & { ref: SessionRef }, ): Promise { const { address } = params.ref; // A release still in flight holds this session's locks, so queueing on them would have the sweep wait @@ -332,7 +332,7 @@ function budgetElapsedAfter(ms: number): Readonly<{ */ async function settleIdleSessionUnderLock( params: IdleExpirySweepParams & { - ref: { address: string; session: SessionState }; + ref: SessionRef; lockKeys: readonly RequestExecutionLockKey[]; }, ): Promise { @@ -349,7 +349,7 @@ async function settleIdleSessionUnderLock( if (params.closing()) return NOTHING_TO_RETRY; // Re-read under the locks rather than trusting the swept reference: the device may have moved, // and the lock keys were chosen from the pre-lock reading. - const settled = params.sessionStore.get(address); + const settled = params.sessionStore.resolveCurrent(params.ref); if (!settled) return NOTHING_TO_RETRY; if (settled.device.id !== session.device.id) return NOTHING_TO_RETRY; // Re-clocked here too: a command that admitted while this expiry was queuing has finished and @@ -357,8 +357,7 @@ async function settleIdleSessionUnderLock( const atMs = params.now(); if (!isSessionIdleExpired(settled, params.idleExpiryMs, atMs)) return NOTHING_TO_RETRY; const outcome = await settleExpiredSession({ - sessionName: address, - session: settled, + ref: params.sessionStore.refresh(params.ref), idleExpiryMs: params.idleExpiryMs, expiredAtMs: atMs, sessionStore: params.sessionStore, @@ -450,14 +449,14 @@ function rememberRetry( * successor owns the device now, and never blocks the expiry. */ async function settleExpiredSession(params: { - sessionName: string; - session: SessionState; + ref: SessionRef; idleExpiryMs: number; expiredAtMs: number; sessionStore: SessionStore; settleSession: IdleSessionSettler; }): Promise { - const { sessionName, session, idleExpiryMs, expiredAtMs } = params; + const { ref, idleExpiryMs, expiredAtMs } = params; + const { address: sessionName, session } = ref; const deviceKey = session.deviceClaim?.deviceKey; const identity = { session: sessionName, idleExpiryMs, ...(deviceKey ? { deviceKey } : {}) }; if (!(await releaseExpiredSessionResources(params, identity))) return undefined; @@ -477,21 +476,8 @@ async function settleExpiredSession(params: { // stamps COMMITTED onto the record, and a write onto an already-committed transaction is an // idempotent no-op. Finalizing a settle that is being held back would therefore mark a still-live // session's healed script as already published, and no later teardown would ever publish it. - params.sessionStore.finalizeRepairTeardown(session); - // `delete` reports whether a record was still here to remove. Every request-path remover — `close`, - // a replacing `open`, a lease-expiry teardown — removes a session from inside `runAdmitted`, which - // holds the same lock pair this settle holds, and a settle budget bounds only the sweep's WAIT and - // never these locks, so no request can reach this record while the release is running. Daemon - // shutdown is the one remover that takes no lock at all, and it is therefore the only way here. - // The session was ended by someone else, and that owner has already explained it; a marker written - // now would tell the next agent the session died of idleness when something else closed it. - // - // The finalize above already published this session's repair transaction, which is why "a failed - // settle changes nothing" is not this function's contract: publishing belongs to whoever ENDS the - // session, and shutdown ends it by finalizing the same live record, onto which this commit is an - // idempotent no-op. Deferring the finalize to below this guard would instead resolve the healed - // script's event-log directory by map identity, which a deleted record no longer answers correctly. - if (!params.sessionStore.delete(sessionName)) { + params.sessionStore.finalizeRepairTeardown(ref); + if (!params.sessionStore.retire(ref)) { emitDiagnostic({ level: 'info', phase: 'session_idle_expiry_superseded', @@ -528,11 +514,11 @@ async function settleExpiredSession(params: { * this step's diagnostic. */ async function releaseExpiredSessionResources( - params: { session: SessionState; sessionName: string; settleSession: IdleSessionSettler }, + params: { ref: SessionRef; settleSession: IdleSessionSettler }, identity: Readonly>, ): Promise { try { - await params.settleSession(params.session, params.sessionName); + await params.settleSession(params.ref); return true; } catch (error) { emitDiagnostic({ diff --git a/src/daemon/session-artifact-paths.ts b/src/daemon/session-artifact-paths.ts index d456d4d199..b445ed1ec1 100644 --- a/src/daemon/session-artifact-paths.ts +++ b/src/daemon/session-artifact-paths.ts @@ -1,6 +1,7 @@ import path from 'node:path'; import type { DiagnosticsRecordRef } from '@agent-device/kernel/errors'; -import { safeSessionName } from '@agent-device/host-kit/session-paths'; +import { AppError } from '@agent-device/kernel/errors'; +import { isSafeSessionSegment, safeSessionName } from '@agent-device/host-kit/session-paths'; /** Path to session-scoped platform subprocess output, such as Apple runner xcodebuild logs. */ export function resolveSessionRunnerLogPath(sessionDir: string): string { @@ -49,3 +50,27 @@ export function resolveRemoteRequestDiagnosticsPath( ref.requestId, ); } + +/** + * The one place a session name becomes a directory, so the invariant that every + * session dir lies beneath `sessionsDir` is enforced here rather than by each + * caller: `.` and `..` survive `safeSessionName` and would resolve to the + * sessions dir itself or the daemon state dir above it. + */ +export function resolveSessionDir(sessionsDir: string, sessionName: string): string { + if (!isSafeSessionSegment(sessionName)) { + throw new AppError( + 'INVALID_ARGS', + `Invalid session name ${JSON.stringify(sessionName)}: a session name cannot be empty, ".", or "..".`, + ); + } + return path.join(sessionsDir, safeSessionName(sessionName)); +} + +export function resolveSessionAppLogPath(sessionsDir: string, address: string): string { + return path.join(resolveSessionDir(sessionsDir, address), 'app.log'); +} + +export function resolveSessionAppLogPidPath(sessionsDir: string, address: string): string { + return path.join(resolveSessionDir(sessionsDir, address), 'app-log.pid'); +} diff --git a/src/daemon/session-capture-binding.ts b/src/daemon/session-capture-binding.ts new file mode 100644 index 0000000000..97555f0b93 --- /dev/null +++ b/src/daemon/session-capture-binding.ts @@ -0,0 +1,87 @@ +import type { + DurableCaptureSessionBinding, + DurableCaptureSessionResource, +} from '@agent-device/capture-kit/durable-capture'; +import { AppError } from '@agent-device/kernel/errors'; +import type { SessionRef, SessionState } from './session-state.ts'; +import type { SessionStore } from './session-store.ts'; + +export function bindSessionCapture( + sessionStore: SessionStore, + ref: SessionRef, + slot: Readonly<{ + read(session: SessionState): DurableCaptureSessionResource | undefined; + write(resource: DurableCaptureSessionResource | undefined): void; + }>, +): DurableCaptureSessionBinding { + let retained = slot.read(sessionStore.resolveCurrent(ref) ?? ref.session); + const assertAdoptable = (): void => { + sessionStore.assertAdmissionOpen(ref.address); + if (slot.read(sessionStore.requireCurrent(ref))) { + throw new AppError('COMMAND_FAILED', 'Session capture resource has changed', { + reason: 'session_resource_changed', + session: ref.address, + }); + } + }; + return Object.freeze({ + address: ref.address, + sessionDir: sessionStore.resolveSessionDir(ref.address), + read: () => { + const current = sessionStore.resolveCurrent(ref); + if (current) retained = slot.read(current); + return retained; + }, + assertAdoptable, + canPersist: () => { + const current = sessionStore.resolveCurrent(ref); + return current !== undefined && slot.read(current) === undefined; + }, + adopt: (resource) => { + assertAdoptable(); + slot.write(resource); + retained = resource; + }, + clear: (expected) => { + const current = sessionStore.resolveCurrent(ref); + if (!current) return 'retired'; + const active = slot.read(current); + if ( + active?.handle !== expected.handle || + active.envelope.fence.token !== expected.envelope.fence.token || + active.envelope.fence.generation !== expected.envelope.fence.generation + ) + return 'resource-changed'; + slot.write(undefined); + retained = undefined; + return 'cleared'; + }, + }); +} + +export function bindSessionAudioProbe(sessionStore: SessionStore, ref: SessionRef) { + return bindSessionCapture(sessionStore, ref, { + read: (session) => session.audioProbe, + write: (audioProbe) => { + sessionStore.update(ref, { audioProbe }); + }, + }); +} + +export function bindSessionPerfCapture(sessionStore: SessionStore, ref: SessionRef) { + return bindSessionCapture(sessionStore, ref, { + read: (session) => session.perfCapture, + write: (perfCapture) => { + sessionStore.update(ref, { perfCapture }); + }, + }); +} + +export function bindSessionScreenRecording(sessionStore: SessionStore, ref: SessionRef) { + return bindSessionCapture(sessionStore, ref, { + read: (session) => session.screenRecording, + write: (screenRecording) => { + sessionStore.update(ref, { screenRecording }); + }, + }); +} diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts index 7ca47b926c..05af3277fa 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts @@ -347,3 +347,35 @@ test('close expires the ref frame immediately before its admitted platform mutat expect(response?.ok).toBe(true); expect(mockDispatch).toHaveBeenCalledOnce(); }); + +test('app-only close expires the rebuilt record after admission, preserving the captured snapshot', async () => { + const sessionStore = makeSessionStore(); + const address = 'cwd:close:default'; + const session = makeSession('default', { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, + }); + session.appBundleId = 'com.example.app'; + activateCompleteRefFrame(session); + const ref = sessionStore.publish(address, session); + mockInspectDeviceRuntimeFacts.mockImplementationOnce(async (candidate) => { + sessionStore.update(ref, { appName: 'Updated during admission' }); + return lifecycleRuntimeFacts(candidate); + }); + + const response = await close({ + sessionName: address, + sessionStore, + positionals: ['com.example.app'], + internal: { closeAppOnly: true }, + }); + + expect(response?.ok).toBe(true); + expect(refFrameState(sessionStore.requireCurrent(ref))).toBe('expired'); + expect(sessionStore.requireCurrent(ref).appName).toBe('Updated during admission'); + expect(refFrameState(ref.session)).toBe('active'); + expect(mockDispatch).toHaveBeenCalledOnce(); +}); diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-close-resource-cleanup.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-close-resource-cleanup.test.ts index e77972a7f3..41f5193bdf 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-close-resource-cleanup.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-close-resource-cleanup.test.ts @@ -259,3 +259,63 @@ test('close preserves the session and lease when provider release fails so it ca expect(sessionStore.get(sessionName)).toBeUndefined(); expect(leaseRegistry.listActiveLeases()).toHaveLength(0); }); + +test('close cannot retire a replacement session while its provider release waits', async () => { + const sessionStore = makeSessionStore(); + const leaseRegistry = new LeaseRegistry(); + const address = 'cwd:provider-close:default'; + const lease = leaseRegistry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'browserstack', + deviceKey: 'ios:bs-device', + clientId: 'client-a', + }); + const ref = sessionStore.publish(address, { + ...makeSession('default', WEB_DESKTOP_DEVICE), + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + leaseProvider: lease.leaseProvider, + deviceKey: lease.deviceKey, + clientId: lease.clientId, + expiresAt: lease.expiresAt, + }, + }); + let release!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + const providerRelease = vi.fn(async () => { + await held; + return { releasedBy: 'provider' }; + }); + const closing = handleSessionCommands({ + req: { token: 't', session: address, command: 'close', positionals: [], flags: {} }, + sessionName: address, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + leaseRegistry, + leaseLifecycleProvider: { release: providerRelease }, + invoke: noopInvoke, + }); + try { + await vi.waitFor(() => expect(providerRelease).toHaveBeenCalledOnce()); + sessionStore.retire(ref); + const successor = sessionStore.publish(address, makeSession('default', WEB_DESKTOP_DEVICE)); + sessionStore.setRuntimeHints(address, { metroPort: 9090 }); + release(); + expect(await closing).toMatchObject({ + ok: true, + data: { provider: { releasedBy: 'provider' } }, + }); + expect(sessionStore.requireCurrent(successor)).toBe(successor.session); + expect(sessionStore.getRuntimeHints(address)).toEqual({ metroPort: 9090 }); + expect(leaseRegistry.listActiveLeases()).toHaveLength(0); + } finally { + release(); + await closing.catch(() => {}); + } +}); diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-close-script.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-close-script.test.ts index 34acf3f61e..ad90ee5d3b 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-close-script.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-close-script.test.ts @@ -34,7 +34,7 @@ function setup(name: string, session = makeIosSession(name, { appBundleId: 'com. roots.push(root); const sessionsDir = path.join(root, 'sessions'); const sessionStore = new SessionStore(sessionsDir); - sessionStore.set(name, session); + const ref = sessionStore.publish(name, session); const req: DaemonRequest = { token: 'token', session: name, @@ -42,18 +42,18 @@ function setup(name: string, session = makeIosSession(name, { appBundleId: 'com. positionals: [], flags: {}, }; - return { req, session, sessionStore, sessionsDir }; + return { req, ref, session, sessionStore, sessionsDir }; } test('failed repair publication removes only its synthetic close before retry', () => { - const { req, session, sessionStore } = setup( + const { req, ref, session, sessionStore } = setup( 'repair', makeRepairCompleteSession('repair', { appBundleId: 'com.example.app' }), ); const failure = new AppError('COMMAND_FAILED', 'publish failed'); vi.spyOn(sessionStore, 'writeSessionLog').mockReturnValue({ written: false, error: failure }); - expect(commitRepairScriptBeforeClose(sessionStore, session, req)).toEqual({ + expect(commitRepairScriptBeforeClose(sessionStore, ref, req)).toEqual({ kind: 'failed', error: failure, }); @@ -92,7 +92,7 @@ test('repair close failure keeps normalized metadata and is explicitly retriable }); test('ordinary publication failure retains its close action after making the error non-retriable', () => { - const { req, session, sessionStore } = setup('ordinary'); + const { req, ref, session, sessionStore } = setup('ordinary'); const failure = new AppError('COMMAND_FAILED', 'target exists', { reason: 'target-exists', hint: 'Retry close.', @@ -103,7 +103,7 @@ test('ordinary publication failure retains its close action after making the err const result = finalizeOrdinaryCloseScript({ req: { ...req, flags: { saveScript: true } }, - session, + ref, sessionStore, platformCloseError: undefined, }); @@ -126,7 +126,7 @@ test('ordinary publication failure retains its close action after making the err // that promise: the plain-close teardown path must write nothing. test('#1533: bare close on an aborted authoring session writes no script', () => { - const { req, session, sessionStore, sessionsDir } = setup( + const { req, ref, sessionStore, sessionsDir } = setup( 'aborted', makeIosSession('aborted', { appBundleId: 'com.example.app', @@ -139,7 +139,7 @@ test('#1533: bare close on an aborted authoring session writes no script', () => expect( finalizeOrdinaryCloseScript({ req, - session, + ref, sessionStore, platformCloseError: undefined, }), @@ -150,7 +150,7 @@ test('#1533: bare close on an aborted authoring session writes no script', () => }); test('#1533: an ordinary armed authoring session still publishes on bare close', () => { - const { req, session, sessionStore, sessionsDir } = setup( + const { req, ref, sessionStore, sessionsDir } = setup( 'armed', makeIosSession('armed', { appBundleId: 'com.example.app', @@ -162,7 +162,7 @@ test('#1533: an ordinary armed authoring session still publishes on bare close', expect( finalizeOrdinaryCloseScript({ req, - session, + ref, sessionStore, platformCloseError: undefined, }), diff --git a/src/daemon/session-lifecycle/internal/__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..202996c16d 100644 --- a/src/daemon/session-lifecycle/internal/session-close-lifecycle-teardown.ts +++ b/src/daemon/session-lifecycle/internal/session-close-lifecycle-teardown.ts @@ -37,8 +37,7 @@ export type SessionCloseTeardownResult = Readonly<{ /** Runs owned resources, native close, native hint cleanup, and final lifecycle disposal. */ export async function runSessionCloseTeardown(params: { req: DaemonRequest; - session: SessionState; - sessionName: string; + ref: SessionRef; logPath: string; sessionStore: SessionStore; lifecycle: CloseRuntime | CloseRuntimeWithRuntimeHintClear; @@ -48,7 +47,7 @@ export async function runSessionCloseTeardown(params: { dispatchTargetedPlatformClose: PlatformCloseDispatcher; finalizeOrdinaryCloseScript(input: { req: DaemonRequest; - session: SessionState; + ref: SessionRef; sessionStore: SessionStore; platformCloseError: unknown; }): Error | undefined; @@ -56,8 +55,7 @@ export async function runSessionCloseTeardown(params: { }): Promise { const { req, - session, - sessionName, + ref, logPath, sessionStore, lifecycle, @@ -67,6 +65,8 @@ export async function runSessionCloseTeardown(params: { dispatchTargetedPlatformClose, finalizeOrdinaryCloseScript, } = params; + const { address: sessionName } = ref; + let session = sessionStore.requireCurrent(ref); const attemptCleanup = async ( step: string, run: () => Promise, @@ -86,11 +86,12 @@ export async function runSessionCloseTeardown(params: { }); const configuredRuntimeHints = sessionStore.getRuntimeHints(sessionName); await stopBestEffortSessionResources( - { address: sessionName, session }, + ref, sessionStore, attemptCleanup, params.platformResourceCleanup, ); + session = sessionStore.requireCurrent(ref); const platformCloseError = repairArmed ? undefined : await dispatchTargetedPlatformClose({ req, session, logPath, lifecycle }); @@ -119,7 +120,7 @@ export async function runSessionCloseTeardown(params: { ); const saveScriptError = repairArmed ? undefined - : finalizeOrdinaryCloseScript({ req, session, sessionStore, platformCloseError }); + : finalizeOrdinaryCloseScript({ req, ref, sessionStore, platformCloseError }); await attemptCleanup('materialized_paths', () => cleanupRetainedMaterializedPathsForSession(sessionName), ); @@ -134,21 +135,11 @@ async function stopBestEffortSessionResources( attemptCleanup: CleanupRunner, platformCleanup: PlatformResourceCleanup, ): Promise { - const { address: sessionName, session } = ref; - // Recording overlay finalization needs the Apple runner, so it runs first. - // `finishSessionScreenRecording` re-reads the stored session by address and - // returns when there is no recording; a second lookup here would only be a - // place to mis-address it. - await attemptCleanup('recording', () => - finishSessionScreenRecording({ session, sessionName, sessionStore }), - ); - await attemptCleanup('app_log', () => stopSessionAppLog({ session, sessionName, sessionStore })); - await attemptCleanup('audio_probe', () => - finishSessionAudioProbe({ session, sessionName, sessionStore }), - ); - await attemptCleanup('perf_capture', () => - stopSessionPerfCapture({ session, sessionName, sessionStore }), - ); + const session = sessionStore.resolveCurrent(ref) ?? ref.session; + await attemptCleanup('recording', () => finishSessionScreenRecording({ ref, sessionStore })); + await attemptCleanup('app_log', () => stopSessionAppLog({ ref, sessionStore })); + await attemptCleanup('audio_probe', () => finishSessionAudioProbe({ ref, sessionStore })); + await attemptCleanup('perf_capture', () => stopSessionPerfCapture({ ref, sessionStore })); await attemptCleanup('platform_snapshot_helper', () => stopSessionSnapshotHelper(session, platformCleanup), ); diff --git a/src/daemon/session-lifecycle/internal/session-close-script.ts b/src/daemon/session-lifecycle/internal/session-close-script.ts index 47fbfc88f2..ec64319b86 100644 --- a/src/daemon/session-lifecycle/internal/session-close-script.ts +++ b/src/daemon/session-lifecycle/internal/session-close-script.ts @@ -3,7 +3,7 @@ import { successText } from '@agent-device/kernel/success-text'; import type { CommandFlags } from '@agent-device/contracts/command'; import type { SessionStore } from '../../session-store.ts'; import type { DaemonRequest, DaemonResponse } from '../../daemon-request.ts'; -import type { SessionState } from '../../session-state.ts'; +import type { SessionRef, SessionState } from '../../session-state.ts'; import { NO_SCRIPT_PUBLICATION, scriptTargetPath } from '../../session-script-publication-state.ts'; import { effectiveWriteForce, @@ -33,15 +33,16 @@ function recordCloseAction( export function commitRepairScriptBeforeClose( sessionStore: SessionStore, - session: SessionState, + ref: SessionRef, req: DaemonRequest, ): RepairCloseCommit { + const session = sessionStore.requireCurrent(ref); if (!isRepairArmedSession(session)) return { kind: 'not-armed' }; const actionsBeforeClose = session.actions.length; recordCloseAction(sessionStore, session, req); const alreadyPublished = isSessionScriptPublished(session); - const result = sessionStore.writeSessionLog(session, { + const result = sessionStore.writeSessionLog(ref, { force: effectiveWriteForce(session, req.flags?.force), }); if (result.written) return { kind: 'committed', path: result.path }; @@ -77,11 +78,12 @@ export function buildRetriableRepairCloseFailureResponse( export function finalizeOrdinaryCloseScript(params: { req: DaemonRequest; - session: SessionState; + ref: SessionRef; sessionStore: SessionStore; platformCloseError: unknown; }): AppError | undefined { - const { req, session, sessionStore, platformCloseError } = params; + const { req, ref, sessionStore, platformCloseError } = params; + const session = sessionStore.requireCurrent(ref); if (!platformCloseError) { recordCloseAction(sessionStore, session, req); } @@ -90,7 +92,7 @@ export function finalizeOrdinaryCloseScript(params: { // session default rather than the request's explicit path — the lifecycle armed by `open` is // what authorizes the write, and it is untouched by that failure. try { - const result = sessionStore.writeSessionLog(session, { + const result = sessionStore.writeSessionLog(ref, { force: effectiveWriteForce(session, req.flags?.force), }); if (result.written) markCloseGeneratedPublicationDone(session, result.path); diff --git a/src/daemon/session-lifecycle/internal/session-close.ts b/src/daemon/session-lifecycle/internal/session-close.ts index 889cc5016b..7654c1bc0e 100644 --- a/src/daemon/session-lifecycle/internal/session-close.ts +++ b/src/daemon/session-lifecycle/internal/session-close.ts @@ -2,7 +2,7 @@ import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { AppError, normalizeError } from '@agent-device/kernel/errors'; import type { LeaseLifecycleProvider, TargetShutdownResult } from '@agent-device/contracts/device'; import type { DaemonRequest, DaemonResponse } from '../../daemon-request.ts'; -import type { SessionState } from '../../session-state.ts'; +import type { SessionRef, SessionState } from '../../session-state.ts'; import { SessionStore } from '../../session-store.ts'; import { successText, withSuccessText } from '@agent-device/kernel/success-text'; import { resolveCommandDevice } from '../../session-device-resolution.ts'; @@ -68,12 +68,13 @@ const shouldDispatchPlatformClose = (req: DaemonRequest, session: SessionState): async function prepareRepairClose(params: { req: DaemonRequest; - session: SessionState; + ref: SessionRef; logPath: string; sessionStore: SessionStore; lifecycle: CloseRuntime | CloseRuntimeWithRuntimeHintClear; }): Promise { - const { req, session, logPath, sessionStore, lifecycle } = params; + const { req, ref, logPath, sessionStore, lifecycle } = params; + const session = sessionStore.requireCurrent(ref); const repairArmed = isRepairArmedSession(session); const closeReceipt = JSON.stringify(req.positionals ?? []); if (repairArmed && !hasRepairPlatformCloseReceipt(session, closeReceipt)) { @@ -93,9 +94,9 @@ async function prepareRepairClose(params: { ), }; } - recordRepairPlatformClose(session, closeReceipt); + recordRepairPlatformClose(sessionStore.requireCurrent(ref), closeReceipt); } - const repairCommit = commitRepairScriptBeforeClose(sessionStore, session, req); + const repairCommit = commitRepairScriptBeforeClose(sessionStore, ref, req); if (repairCommit.kind === 'failed') { // Publication failure retains target, force, and the close receipt; the same-identity retry // skips close dispatch above. @@ -193,8 +194,8 @@ export async function handleSessionCloseCommands( params: SessionCloseCommandInput, ): Promise { const { req, sessionName, logPath, sessionStore, leaseRegistry, leaseLifecycleProvider } = params; - const session = sessionStore.get(sessionName); - if (!session) { + const ref = sessionStore.lookup(sessionName); + if (!ref) { return await closeWithoutSession({ req, logPath, @@ -202,6 +203,7 @@ export async function handleSessionCloseCommands( bindDevice: params.bindDevice, }); } + let session = sessionStore.requireCurrent(ref); assertTerminalRecordingCloseAllowed(req, session); const app = req.positionals?.[0]; if (req.internal?.closeAppOnly === true && !app) { @@ -224,6 +226,7 @@ export async function handleSessionCloseCommands( bindDevice: params.bindDevice, }); if (admission.type === 'response') return admission.response; + session = sessionStore.requireCurrent(ref); // Teardown can restore durable IME state, terminate an app, or shut down a target. All are // mutating leaves, so invalidate the frame before the first teardown phase, not after dispatch. expireRefFrame(session); @@ -238,7 +241,7 @@ export async function handleSessionCloseCommands( } const repair = await prepareRepairClose({ req, - session, + ref, logPath, sessionStore, lifecycle: admission.runtime, @@ -246,8 +249,7 @@ export async function handleSessionCloseCommands( if ('response' in repair) return repair.response; const closed = await runCloseTeardownAndRelease({ req, - session, - sessionName, + ref, logPath, sessionStore, leaseRegistry, @@ -281,8 +283,7 @@ type SessionCloseFinalization = // lease release keeps the session retryable instead (`{kind:'response'}`). async function runCloseTeardownAndRelease(params: { req: DaemonRequest; - session: SessionState; - sessionName: string; + ref: SessionRef; logPath: string; sessionStore: SessionStore; leaseRegistry: LeaseRegistry; @@ -294,8 +295,7 @@ async function runCloseTeardownAndRelease(params: { }): Promise { const { req, - session, - sessionName, + ref, logPath, sessionStore, leaseRegistry, @@ -303,11 +303,11 @@ async function runCloseTeardownAndRelease(params: { lifecycle, clearRuntimeHints, } = params; + const { address: sessionName } = ref; const cleanupFailures: SessionCleanupFailure[] = []; const { platformCloseError, saveScriptError, shutdownResult } = await runSessionCloseTeardown({ req, - session, - sessionName, + ref, logPath, sessionStore, lifecycle, @@ -318,12 +318,14 @@ async function runCloseTeardownAndRelease(params: { finalizeOrdinaryCloseScript, platformResourceCleanup: params.platformResourceCleanup, }); + let session = sessionStore.requireCurrent(ref); const leaseRelease = await releaseProviderLeaseForClose({ session, leaseRegistry, leaseLifecycleProvider, }); if (leaseRelease.response) return { kind: 'response', response: leaseRelease.response }; + session = sessionStore.resolveCurrent(ref) ?? session; const cleanupAggregate = closeCleanupError(sessionName, cleanupFailures); const deviceClaimBlockingError = platformCloseError ?? cleanupAggregate; if (deviceClaimBlockingError) { @@ -340,7 +342,7 @@ async function runCloseTeardownAndRelease(params: { } else { await clearDeviceClaim(session.deviceClaim); } - sessionStore.delete(sessionName); + sessionStore.retire(ref); if (deviceClaimBlockingError) throw deviceClaimBlockingError; if (saveScriptError) throw saveScriptError; return { kind: 'closed', providerData: leaseRelease.providerData, shutdownResult }; diff --git a/src/daemon/session-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); @@ -72,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({ @@ -88,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) }; @@ -115,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 { @@ -138,7 +142,7 @@ async function executeAdmittedPerfPlan( appId: session.appBundleId, kind: plan.request.kind, outPath: plan.request.outPath - ? SessionStore.expandHome(plan.request.outPath, params.req.meta?.cwd) + ? expandSessionPath(plan.request.outPath, params.req.meta?.cwd) : undefined, artifactsDir: path.join( params.sessionStore.ensureSessionDir(params.sessionName), @@ -171,7 +175,7 @@ async function executeAdmittedPerfPlan( const data = await runtime.operations.perfProfileReport({ appId: session.appBundleId, kind: plan.request.kind, - tracePath: SessionStore.expandHome(tracePath, params.req.meta?.cwd), + tracePath: expandSessionPath(tracePath, params.req.meta?.cwd), outPath, template: plan.request.template ?? (last?.kind === 'xctrace' ? last.template : undefined), profile: last, @@ -182,7 +186,7 @@ async function executeAdmittedPerfPlan( } async function startPerfCapture( - params: PerfRuntimeHandlerParams, + params: PerfRuntimeHandlerParams & { ref: SessionRef }, session: SessionState, runtime: Readonly<{ owner: Parameters[0]['owner']; @@ -213,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, @@ -224,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, @@ -247,7 +251,7 @@ function requirePerfCaptureAdmissionLedger( } async function stopPerfCapture( - params: PerfRuntimeHandlerParams, + params: PerfRuntimeHandlerParams & { ref: SessionRef }, session: SessionState, request: Extract, ): Promise { @@ -257,18 +261,15 @@ async function stopPerfCapture( const mismatchMessage = perfCaptureStopMismatch(snapshot, request); if (mismatchMessage) return errorResponse('INVALID_ARGS', mismatchMessage); if (request.outPath) { - capture.handle.setOutputPath(SessionStore.expandHome(request.outPath, params.req.meta?.cwd)); + capture.handle.setOutputPath(expandSessionPath(request.outPath, params.req.meta?.cwd)); } const completion = await finishLivePerfCapture({ intent: 'capture', - session, - sessionName: params.sessionName, - sessionStore: params.sessionStore, + binding: bindSessionPerfCapture(params.sessionStore, params.ref), }); if (request.area === 'cpu') { const profile = readProfileHandoff(completion); - const refreshed = params.sessionStore.get(params.sessionName) ?? session; - params.sessionStore.set(params.sessionName, { ...refreshed, lastPerfProfile: profile }); + params.sessionStore.update(params.ref, { lastPerfProfile: profile }); } return { ok: true, data: completion }; } @@ -395,7 +396,7 @@ function resolveNativeOutPath( requestedPath: string | undefined, fallbackFileName: string, ): string { - if (requestedPath) return SessionStore.expandHome(requestedPath, params.req.meta?.cwd); + if (requestedPath) return expandSessionPath(requestedPath, params.req.meta?.cwd); return path.join( params.sessionStore.ensureSessionDir(params.sessionName), `${timestampToken()}-${fallbackFileName}`, diff --git a/src/daemon/session-recovery-hints.test.ts b/src/daemon/session-recovery-hints.test.ts index c1f0930fa4..15aceaa94d 100644 --- a/src/daemon/session-recovery-hints.test.ts +++ b/src/daemon/session-recovery-hints.test.ts @@ -1,3 +1,4 @@ +import { makeStoredSessionRef } from '../__tests__/test-utils/store-factory.ts'; import { test, expect } from 'vitest'; import { buildSessionRecoveryHint } from './session-recovery-hints.ts'; import type { SessionRef, SessionState } from './session-state.ts'; @@ -12,9 +13,8 @@ import { IOS_SIMULATOR } from '../__tests__/test-utils/device-fixtures.ts'; const SCOPED_ADDRESS = 'cwd:8bea844ab16aa9b3:default'; function scopedRef(overrides: Partial = {}): SessionRef { - return { - address: SCOPED_ADDRESS, - session: { + return makeStoredSessionRef( + { name: 'default', sessionScope: { kind: 'cwd', id: '8bea844ab16aa9b3' }, device: IOS_SIMULATOR, @@ -22,7 +22,8 @@ function scopedRef(overrides: Partial = {}): SessionRef { actions: [], ...overrides, }, - }; + SCOPED_ADDRESS, + ); } test('device-in-use recovery names the address --session accepts, not the public name', () => { @@ -55,10 +56,7 @@ test('a recording session recovery uses the address for both close and record st test('an explicitly named session addresses itself unchanged', () => { const hint = buildSessionRecoveryHint( - { - address: 'checkout', - session: { ...scopedRef().session, name: 'checkout', sessionScope: undefined }, - }, + makeStoredSessionRef({ ...scopedRef().session, name: 'checkout', sessionScope: undefined }), 'device-in-use', ); @@ -81,10 +79,7 @@ test('a device or target conflict does not offer a platform session it cannot an test('selector-conflict recovery offers no platform session to a hand-named session', () => { const hint = buildSessionRecoveryHint( - { - address: 'checkout', - session: { ...scopedRef().session, name: 'checkout', sessionScope: undefined }, - }, + makeStoredSessionRef({ ...scopedRef().session, name: 'checkout', sessionScope: undefined }), 'selector-conflict', ); diff --git a/src/daemon/session-snapshot.ts b/src/daemon/session-snapshot.ts index 0f0bb71822..6e70a1b8dd 100644 --- a/src/daemon/session-snapshot.ts +++ b/src/daemon/session-snapshot.ts @@ -2,7 +2,8 @@ import { randomInt } from 'node:crypto'; import type { SettleObservation } from '@agent-device/contracts/interaction'; import type { SnapshotState } from '@agent-device/kernel/snapshot'; import { activatePartialRefFrame, refFrameEpoch, refFrameState } from './ref-frame.ts'; -import type { SessionState } from './session-state.ts'; +import type { SessionRef, SessionState } from './session-state.ts'; +import type { SessionStore } from './session-store.ts'; /** * Warning attached to a read of an `@ref` argument once the ref frame has @@ -16,8 +17,7 @@ export const STALE_SNAPSHOT_REFS_WARNING = /** * The single daemon-side write choke point for replacing a session's stored - * snapshot outside the snapshot/diff command (`buildNextSnapshotSession`, - * src/daemon/snapshot-runtime.ts). It advances the observation generation but + * snapshot outside the snapshot/diff command. It advances the observation generation but * does NOT touch the ref frame: replacing the latest observation is an * operational read, so it never expires, reactivates, or reindexes the * authorized frame (ADR 0014). Frame lifetime is owned solely by @@ -38,35 +38,20 @@ export function setSessionSnapshot(session: SessionState, snapshot: SnapshotStat } } -/** - * The same lineage rule, applied to a freshly BUILT record instead of the stored one. - * `snapshot-runtime.ts` constructs a new `SessionState` rather than mutating the one in the - * store, so it cannot go through `setSessionSnapshot` — but the invariant it has to honour is - * identical, and it is the invariant that matters: #1076 versioned refs require the observation - * counter to advance exactly when the stored tree is replaced, for `snapshot` and `diff` alike, - * and the scope source must describe the tree that actually ended up there. - * - * Advancing the counter is NOT the same as invalidating client refs, and the comment this - * replaced said otherwise — it claimed a diff leaves refs pinned to the previous generation - * "which is exactly what the pinned warning diagnoses". It does not: `diff` passes - * `issuesRefsToClient: false`, so it never reactivates the frame, and - * `resolveRefStalenessWarning` compares a pin against the frame EPOCH rather than this counter, - * precisely so a capture that bumped the counter cannot make a valid pin look stale. A ref - * pinned before a diff therefore keeps resolving, with no warning, by design (ADR 0014). - * `session-snapshot.test.ts` pins that outcome so the claim cannot drift back. - * - * Both fields move together, here, next to the writer they have to agree with. They used to be - * assigned at the call site, which is how the rule came to have two statements of itself in two - * modules. - */ -export function setSnapshotLineage( +/** Replaces a snapshot/diff observation and its scoped lineage without issuing client refs. */ +export function setCommandSnapshot( session: SessionState, params: { + snapshot: SnapshotState; scopeSource: SnapshotState | undefined; keptCurrentSnapshot: boolean; previousGeneration: number | undefined; }, ): void { + session.snapshot = params.snapshot; + if (params.snapshot.comparisonSafe === true) { + session.lastComparisonSafeSnapshot = params.snapshot; + } session.snapshotScopeSource = params.scopeSource; session.snapshotGeneration = params.keptCurrentSnapshot ? params.previousGeneration @@ -136,10 +121,12 @@ export function markSessionPartialRefsIssued(session: SessionState, refs: Iterab * rule has one implementation beside the partial-frame primitive it wraps. */ export function issueSettleRefs( - session: SessionState, + ref: SessionRef | undefined, + sessionStore: SessionStore, settle: SettleObservation | undefined, ): number | undefined { - if (!settle?.diff) return undefined; + if (!ref || !settle?.diff) return undefined; + const session = sessionStore.requireCurrent(ref); markSessionPartialRefsIssued(session, collectSettleIssuedRefBodies(settle)); return session.snapshotGeneration; } diff --git a/src/daemon/session-state.ts b/src/daemon/session-state.ts index 8e3350ff04..bf5ee40bb8 100644 --- a/src/daemon/session-state.ts +++ b/src/daemon/session-state.ts @@ -108,10 +108,11 @@ export type PostGestureStabilization = { * target takes this pair rather than a bare record, so it cannot be handed a session whose address * was never resolved. */ -export type SessionRef = { +export type SessionRef = Readonly<{ address: string; session: SessionState; -}; + lifetime: object; +}>; export type SessionState = { name: string; diff --git a/src/daemon/session-store.ts b/src/daemon/session-store.ts index 1598dd9414..00baf86df3 100644 --- a/src/daemon/session-store.ts +++ b/src/daemon/session-store.ts @@ -4,11 +4,12 @@ import { AppError } from '@agent-device/kernel/errors'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import type { SessionRef, SessionRuntimeHints, SessionState } from './session-state.ts'; import { recordActionEntry, type RecordActionEntry } from './session-action-recorder.ts'; +import { isSafeSessionSegment, safeSessionName } from '@agent-device/host-kit/session-paths'; import { - expandSessionPath, - isSafeSessionSegment, - safeSessionName, -} from '@agent-device/host-kit/session-paths'; + resolveSessionDir, + resolveSessionAppLogPath, + resolveSessionAppLogPidPath, +} from './session-artifact-paths.ts'; import { readRepairTombstoneFile, resolveRepairTombstonePath, @@ -42,9 +43,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 +66,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 +153,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 +163,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 +177,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 { @@ -154,10 +231,8 @@ export class SessionStore { ); } - writeSessionLog( - session: SessionState, - options?: SessionScriptWriteOptions, - ): SessionScriptWriteResult { + writeSessionLog(ref: SessionRef, options?: SessionScriptWriteOptions): SessionScriptWriteResult { + const session = this.requireCurrent(ref); const result = this.scriptWriter.write(session, options); if (result.written) { emitDiagnostic({ @@ -196,22 +271,24 @@ export class SessionStore { * ordinary bounded `REPAIR_SESSION_EXPIRED` tombstone. A no-op for ordinary * (non-repair) sessions beyond the existing `writeSessionLog`. */ - finalizeRepairTeardown(session: SessionState): void { + finalizeRepairTeardown(ref: SessionRef): void { + const session = this.resolveCurrent(ref); + if (!session) return; this.recordRepairFinalizeCloseIfCommitting(session); // #1258: no live request here (idle-reap/daemon-shutdown teardown), so // the only source of `force` is whatever was persisted on the session at // arm time. - const result = this.writeSessionLog(session, { + const result = this.writeSessionLog(ref, { force: effectiveWriteForce(session, undefined), }); if (isUncommittedRepairSession(session)) { if (!result.written && result.error) { - this.writeRepairTombstone(session, REPAIR_TOMBSTONE_TTL_MS, { + this.writeRepairTombstone(ref, REPAIR_TOMBSTONE_TTL_MS, { code: String(result.error.code), message: result.error.message, }); } else { - this.writeRepairTombstone(session); + this.writeRepairTombstone(ref); } } } @@ -247,15 +324,17 @@ export class SessionStore { * teardown. */ writeRepairTombstone( - session: SessionState, + ref: SessionRef, ttlMs = REPAIR_TOMBSTONE_TTL_MS, commitFailure?: { code: string; message: string }, ): void { + const session = this.resolveCurrent(ref); + if (!session) return; try { - const dir = this.resolveSessionDir(session.name); + const dir = this.resolveSessionDir(ref.address); fs.mkdirSync(dir, { recursive: true }); const tombstone: RepairSessionTombstone = { - owner: session.name, + owner: ref.address, reapedAt: Date.now(), expiresAt: Date.now() + ttlMs, ...(repairSessionSourcePath(session) @@ -263,13 +342,13 @@ export class SessionStore { : {}), ...(commitFailure ? { commitFailure } : {}), }; - fs.writeFileSync(this.repairTombstonePath(session.name), `${JSON.stringify(tombstone)}\n`); + fs.writeFileSync(this.repairTombstonePath(ref.address), `${JSON.stringify(tombstone)}\n`); } catch (error) { emitDiagnostic({ level: 'warn', phase: 'repair_tombstone_write_failed', data: { - session: session.name, + session: ref.address, error: error instanceof Error ? error.message : String(error), }, }); @@ -298,7 +377,7 @@ export class SessionStore { * never built, and its own `createdAt` already starts that session's deadline clock. */ noteSessionActivity(address: string, atMs: number = Date.now()): void { - const session = this.sessions.get(address); + const session = this.get(address); if (!session) return; session.lastActivityAtMs = atMs; } @@ -380,20 +459,8 @@ export class SessionStore { return path.join(this.sessionsDir, `${safeName}-${timestamp}.trace.log`); } - /** - * The one place a session name becomes a directory, so the invariant that every - * session dir lies beneath `sessionsDir` is enforced here rather than by each - * caller: `.` and `..` survive `safeSessionName` and would resolve to the - * sessions dir itself or the daemon state dir above it. - */ resolveSessionDir(sessionName: string): string { - if (!isSafeSessionSegment(sessionName)) { - throw new AppError( - 'INVALID_ARGS', - `Invalid session name ${JSON.stringify(sessionName)}: a session name cannot be empty, ".", or "..".`, - ); - } - return path.join(this.sessionsDir, safeSessionName(sessionName)); + return resolveSessionDir(this.sessionsDir, sessionName); } // Daemon state dir (parent of the `sessions/` dir), matching daemonPaths.baseDir. Called via @@ -411,28 +478,24 @@ export class SessionStore { /** Path to session-scoped app log file. Agent can grep this for token-efficient debugging. */ resolveAppLogPath(sessionName: string): string { - return path.join(this.resolveSessionDir(sessionName), 'app.log'); + return resolveSessionAppLogPath(this.sessionsDir, sessionName); } resolveAppLogPidPath(sessionName: string): string { - return path.join(this.resolveSessionDir(sessionName), 'app-log.pid'); + return resolveSessionAppLogPidPath(this.sessionsDir, sessionName); } resolveEventLogPath(sessionName: string): string { return resolveSessionEventLogPath(this.resolveSessionDir(sessionName)); } - static expandHome(filePath: string, cwd?: string): string { - return expandSessionPath(filePath, cwd); - } - /** * Resolve the map key for a live session object. SessionState.name is the * public session name, while the map key may include cwd/tenant isolation. */ resolveStoredSessionName(session: SessionState): string { - for (const [name, value] of this.sessions) { - if (value === session) return name; + 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' }); } diff --git a/src/daemon/snapshot-command-runtime.ts b/src/daemon/snapshot-command-runtime.ts index a83882e039..9cc847c758 100644 --- a/src/daemon/snapshot-command-runtime.ts +++ b/src/daemon/snapshot-command-runtime.ts @@ -13,7 +13,7 @@ import { createCommandSurfaceAgentDevice } from '../runtime-command-surface.ts'; import { getRequestSignal } from '@agent-device/host-kit/request'; import { maybeBuildAndroidSnapshotTimeoutFailure } from './android-snapshot-timeout-evidence.ts'; import { captureSnapshot } from './snapshot-capture.ts'; -import { buildSnapshotSession, withSessionlessRunnerCleanup } from './snapshot-session.ts'; +import { createSnapshotSession, withSessionlessRunnerCleanup } from './snapshot-session.ts'; import { resolveSessionScope } from './session-routing.ts'; import { activateCompleteRefFrame } from './ref-frame.ts'; import { @@ -23,15 +23,14 @@ import { import { createDaemonRuntimePolicy } from './runtime-policy.ts'; import { createDaemonRuntimeSessionStore } from './runtime-session.ts'; import { isInteractiveObservation } from './session-action-recorder.ts'; -import { setSnapshotLineage } from './session-snapshot.ts'; +import { setCommandSnapshot } from './session-snapshot.ts'; import { SessionStore } from './session-store.ts'; import { resolveBoundSnapshotCaptureRuntime, type SnapshotRuntimeRouteParams, } from './snapshot-runtime-binding.ts'; import type { DaemonRequest, DaemonResponse, DaemonResponseData } from './daemon-request.ts'; -import type { SessionState } from './session-state.ts'; -import type { SessionScope } from '@agent-device/contracts/session'; +import type { SessionRef, SessionState } from './session-state.ts'; export type SnapshotRuntimeRecord = | { kind: 'snapshot'; nodes: number; truncated: boolean | undefined } @@ -45,10 +44,11 @@ export type SnapshotRuntimeRecord = type SnapshotRuntimeCommandParams = SnapshotRuntimeRouteParams & { command: 'snapshot' | 'diff'; execute(params: { - runtime: ReturnType; + runtime: ReturnType['runtime']; sessionName: string; req: DaemonRequest; snapshotScope: string | undefined; + getSession(): SessionState | undefined; }): Promise<{ data: DaemonResponseData; record: SnapshotRuntimeRecord }>; }; @@ -58,27 +58,32 @@ export async function dispatchSnapshotRuntimeCommand( ): Promise { const capture = await resolveBoundSnapshotCaptureRuntime(params, params.command); if (!capture.ok) return capture.response; - const { session, device, snapshotScope } = capture; + const { ref, session, device, snapshotScope } = capture; return await withSessionlessRunnerCleanup( session, device, async () => { const { req, sessionName, logPath, sessionStore } = params; const capturedQuality: CapturedSnapshotQuality = {}; - const runtime = createSnapshotRuntime({ + const { runtime, sessions } = createSnapshotRuntime({ req, sessionName, logPath, sessionStore, + ref, session, device, snapshotScope, capturedQuality, captureSnapshotData: capture.captureSnapshot, }); + const getSession = () => { + const currentRef = sessions.getRef(); + return currentRef ? sessionStore.requireCurrent(currentRef) : undefined; + }; let result: Awaited>; try { - result = await params.execute({ runtime, sessionName, req, snapshotScope }); + result = await params.execute({ runtime, sessionName, req, snapshotScope, getSession }); } catch (error) { const timeoutResponse = await maybeBuildAndroidSnapshotTimeoutFailure({ error, @@ -92,14 +97,16 @@ export async function dispatchSnapshotRuntimeCommand( if (!timeoutResponse) throw error; return timeoutResponse; } + const current = getSession(); recordSnapshotRuntimeAction({ req, sessionName, sessionStore, + session: current, result: result.record, }); const data = applyRecoveredWarningLatch({ - session: sessionStore.get(sessionName), + session: current, data: result.data, verdict: capturedQuality.value, internalObservation: req.internal?.observationOnly === true, @@ -118,6 +125,7 @@ function createSnapshotRuntime(params: { sessionName: string; logPath: string; sessionStore: SessionStore; + ref: SessionRef | undefined; session: SessionState | undefined; device: SessionState['device']; snapshotScope: string | undefined; @@ -125,7 +133,48 @@ function createSnapshotRuntime(params: { captureSnapshotData: () => Promise; }) { const { req, sessionName, logPath, sessionStore, session, device, snapshotScope } = params; - return createCommandSurfaceAgentDevice({ + const sessions = createDaemonRuntimeSessionStore({ + sessionName, + sessionStore, + ref: params.ref, + recordOptions: { includeSnapshot: true }, + setRecord: (record, current, ref) => { + const snapshotRecord = assertSnapshotSessionRecord(record); + const keepCurrentSnapshot = shouldKeepCurrentSnapshot( + current, + snapshotRecord, + isRefScopedSnapshot(req), + ); + const snapshot = keepCurrentSnapshot ? current.snapshot : snapshotRecord.snapshot; + const nextSession: SessionState = + current ?? + createSnapshotSession({ + sessionName, + sessionScope: resolveSessionScope(req), + device, + snapshot, + appBundleId: record.appBundleId, + }); + nextSession.appName = record.appName ?? current?.appName; + setCommandSnapshot(nextSession, { + snapshot, + scopeSource: resolveNextSnapshotScopeSource({ + current, + keepCurrentSnapshot, + refScopedSnapshot: isRefScopedSnapshot(req), + }), + keptCurrentSnapshot: keepCurrentSnapshot, + previousGeneration: current?.snapshotGeneration, + }); + reactivateCompleteFrameIfIssuing( + nextSession, + keepCurrentSnapshot, + req.command === 'snapshot' && req.internal?.observationOnly !== true, + ); + return ref ?? sessionStore.publish(sessionName, nextSession); + }, + }); + const runtime = createCommandSurfaceAgentDevice({ backend: createDaemonSnapshotBackend({ req, logPath, @@ -137,65 +186,9 @@ function createSnapshotRuntime(params: { }), ...createDaemonRuntimePolicy('snapshot'), signal: getRequestSignal(req.meta?.requestId), - sessions: createDaemonRuntimeSessionStore({ - sessionName, - getSession: () => sessionStore.get(sessionName), - recordOptions: { includeSnapshot: true }, - setRecord: (record) => { - const snapshotRecord = assertSnapshotSessionRecord(record); - const current = sessionStore.get(sessionName); - sessionStore.set( - sessionName, - buildNextSnapshotSession({ - current, - sessionName, - sessionScope: resolveSessionScope(req), - device, - record: snapshotRecord, - refScopedSnapshot: isRefScopedSnapshot(req), - // Only snapshot publishes the complete stored tree. A diff refreshes the - // observation but leaves the client's existing ref authorization unchanged. - issuesRefsToClient: - req.command === 'snapshot' && req.internal?.observationOnly !== true, - }), - ); - }, - }), - }); -} - -function buildNextSnapshotSession(params: { - current: SessionState | undefined; - sessionName: string; - sessionScope: SessionScope; - device: SessionState['device']; - record: CommandSessionRecord & { snapshot: NonNullable }; - refScopedSnapshot: boolean; - issuesRefsToClient: boolean; -}): SessionState { - const { current, sessionName, sessionScope, device, record, refScopedSnapshot } = params; - const keepCurrentSnapshot = shouldKeepCurrentSnapshot(current, record, refScopedSnapshot); - const snapshot = keepCurrentSnapshot ? current.snapshot : record.snapshot; - const nextSession = buildSnapshotSession({ - session: current, - sessionName, - sessionScope, - device, - snapshot, - appBundleId: record.appBundleId, + sessions, }); - setSnapshotLineage(nextSession, { - scopeSource: resolveNextSnapshotScopeSource({ - current, - keepCurrentSnapshot, - refScopedSnapshot, - }), - keptCurrentSnapshot: keepCurrentSnapshot, - previousGeneration: current?.snapshotGeneration, - }); - reactivateCompleteFrameIfIssuing(nextSession, keepCurrentSnapshot, params.issuesRefsToClient); - if (record.appName) nextSession.appName = record.appName; - return nextSession; + return { runtime, sessions }; } function isRefScopedSnapshot(req: DaemonRequest): boolean { @@ -273,9 +266,10 @@ function recordSnapshotRuntimeAction(params: { req: DaemonRequest; sessionName: string; sessionStore: SessionStore; + session: SessionState | undefined; result: SnapshotRuntimeRecord; }): void { - const session = params.sessionStore.get(params.sessionName); + const session = params.session; if (!session) return; params.sessionStore.recordAction(session, { command: params.req.command, diff --git a/src/daemon/snapshot-runtime-binding.ts b/src/daemon/snapshot-runtime-binding.ts index 909e4734a4..f4df411463 100644 --- a/src/daemon/snapshot-runtime-binding.ts +++ b/src/daemon/snapshot-runtime-binding.ts @@ -19,7 +19,7 @@ import { import type { PlatformResourceCleanup } from './platform-resource-cleanup.ts'; import { SessionStore } from './session-store.ts'; import type { DaemonRequest, DaemonResponse } from './daemon-request.ts'; -import type { SessionState } from './session-state.ts'; +import type { SessionRef, SessionState } from './session-state.ts'; import { admitRuntimePlan, requireRuntimeBinding, @@ -51,6 +51,7 @@ export type SnapshotRuntimeRouteParams = { type ResolvedSnapshotCaptureRuntime = | Readonly<{ ok: true; + ref: SessionRef | undefined; session: SessionState | undefined; device: SessionState['device']; snapshotScope: string | undefined; @@ -134,7 +135,7 @@ export async function resolveBoundSnapshotCaptureRuntime( command: 'snapshot' | 'diff', ): Promise { const { req, sessionName, sessionStore } = params; - const { session, device } = await resolveSessionDevice(sessionStore, sessionName, req.flags); + const { ref, session, device } = await resolveSessionDevice(sessionStore, sessionName, req.flags); const resolvedScope = resolveSnapshotScope(req.flags?.snapshotScope, session); if (!resolvedScope.ok) return { ok: false, response: resolvedScope }; @@ -161,6 +162,7 @@ export async function resolveBoundSnapshotCaptureRuntime( }); return Object.freeze({ ok: true, + ref, session, device, snapshotScope: resolvedScope.scope, diff --git a/src/daemon/snapshot-runtime.ts b/src/daemon/snapshot-runtime.ts index b7cf97cf30..7475f43781 100644 --- a/src/daemon/snapshot-runtime.ts +++ b/src/daemon/snapshot-runtime.ts @@ -24,6 +24,7 @@ export async function dispatchSnapshotViaRuntime( sessionName: resolvedSessionName, req: request, snapshotScope, + getSession, }) => { const result = await agentRuntime.capture.snapshot({ session: resolvedSessionName, @@ -34,16 +35,13 @@ export async function dispatchSnapshotViaRuntime( // This request's own capture, read here rather than off the stored snapshot: a snapshot that // failed before capturing must not inherit the previous command's repair (#2682). if (result.targetActivation) captureProof.targetActivation ??= result.targetActivation; - const refsGeneration = publishedSnapshotGeneration( - request, - params.sessionStore.get(resolvedSessionName), - ); + const refsGeneration = publishedSnapshotGeneration(request, getSession()); const publicNodes = stripAndroidSystemChromeProvenance(result.nodes); const publicResult = copySnapshotClickabilityEvidence( result, publicNodes === result.nodes ? result : { ...result, nodes: publicNodes }, ); - const session = params.sessionStore.get(resolvedSessionName); + const session = getSession(); const fallbackScreenshot = await captureSparseFallbackScreenshot({ req: request, session, diff --git a/src/daemon/snapshot-session.ts b/src/daemon/snapshot-session.ts index 0322551409..ea80c1bf7d 100644 --- a/src/daemon/snapshot-session.ts +++ b/src/daemon/snapshot-session.ts @@ -11,9 +11,10 @@ export async function resolveSessionDevice( sessionName: string, flags: DaemonRequest['flags'], ) { - const session = sessionStore.get(sessionName); + const ref = sessionStore.lookup(sessionName); + const session = ref?.session; const device = session?.device ?? (await resolveTargetDevice(flags ?? {})); - return { session, device }; + return { ref, session, device }; } export async function withSessionlessRunnerCleanup( @@ -52,23 +53,14 @@ export function recordIfSession( }); } -export function buildSnapshotSession(params: { - session: SessionState | undefined; +export function createSnapshotSession(params: { sessionName: string; sessionScope: SessionScope; device: SessionState['device']; snapshot: SessionState['snapshot']; appBundleId?: string; }): SessionState { - const { session, sessionName, sessionScope, device, snapshot, appBundleId } = params; - if (session) { - return { - ...session, - snapshot, - lastComparisonSafeSnapshot: - snapshot?.comparisonSafe === true ? snapshot : session.lastComparisonSafeSnapshot, - }; - } + const { sessionName, sessionScope, device, snapshot, appBundleId } = params; return { name: sessionName, sessionScope, diff --git a/src/daemon/wait-runtime.ts b/src/daemon/wait-runtime.ts index 766f7df922..035b80aa42 100644 --- a/src/daemon/wait-runtime.ts +++ b/src/daemon/wait-runtime.ts @@ -22,7 +22,7 @@ import { } from './selector-runtime.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from './request-runtime-binding.ts'; import type { DaemonRequest, DaemonResponse } from './daemon-request.ts'; -import type { SessionState } from './session-state.ts'; +import type { SessionRef, SessionState } from './session-state.ts'; import { maybeWaitTimeoutSurfaceResponse } from './wait-current-surface.ts'; import { withCaptureDisclosures } from './capture-disclosure.ts'; import { @@ -39,7 +39,7 @@ export async function dispatchWaitViaRuntime(params: DispatchWaitParams): Promis const parsedOrResponse = parseWaitRequest(req); if ('ok' in parsedOrResponse) return parsedOrResponse; const parsed = parsedOrResponse; - const { session, device } = await resolveSessionDevice(sessionStore, sessionName, req.flags); + const { ref, session, device } = await resolveSessionDevice(sessionStore, sessionName, req.flags); // ADR 0019: facts are the only support authority, through the selector family's one // admit-then-bind entry. A duration wait observes nothing, so it never asks for a binding — // exactly the cell legacy admission skipped by testing `parsed.kind !== 'sleep'`. @@ -58,7 +58,7 @@ export async function dispatchWaitViaRuntime(params: DispatchWaitParams): Promis // A pure sleep consumes no capture, so it never earns the system-surface disclosure below. if (parsed.kind === 'sleep') { return await executeWaitRequest( - params, + { ...params, ref }, waitParsed, session, device, @@ -75,7 +75,7 @@ export async function dispatchWaitViaRuntime(params: DispatchWaitParams): Promis device, () => executeWaitRequest( - params, + { ...params, ref }, waitParsed, session, device, @@ -146,7 +146,7 @@ function normalizeWaitPositionals( } async function executeWaitRequest( - params: DispatchWaitParams, + params: DispatchWaitParams & { ref: SessionRef | undefined }, parsed: Exclude, session: SessionState | undefined, device: SessionState['device'], @@ -157,6 +157,7 @@ async function executeWaitRequest( const { req, sessionName, sessionStore } = params; const runtime = createSelectorRuntimeForDevice({ ...params, + ref: params.ref, session, device, bound: waitOperations, diff --git a/src/session-repair-tombstone.ts b/src/session-repair-tombstone.ts index 59c2b60a9f..a0fae0461b 100644 --- a/src/session-repair-tombstone.ts +++ b/src/session-repair-tombstone.ts @@ -1,5 +1,6 @@ import path from 'node:path'; import fs from 'node:fs'; +import { AppError } from '@agent-device/kernel/errors'; /** * ADR 0012 decision 6, R7 (C5a): a reaped repair session leaves this bounded @@ -31,20 +32,51 @@ export function resolveRepairTombstonePath(sessionDir: string): string { /** Parses/validates a tombstone file at `tombstonePath`; `undefined` if missing, malformed, or expired. */ export function readRepairTombstoneFile(tombstonePath: string): RepairSessionTombstone | undefined { - let raw: string; try { - raw = fs.readFileSync(tombstonePath, 'utf8'); + return readRepairTombstoneForCleanup(tombstonePath); } catch { return undefined; } - let parsed: RepairSessionTombstone; +} + +function readRepairTombstoneForCleanup(tombstonePath: string): RepairSessionTombstone | undefined { + let raw: string; try { - parsed = JSON.parse(raw) as RepairSessionTombstone; - } catch { - return undefined; + raw = fs.readFileSync(tombstonePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; } - if (typeof parsed?.expiresAt !== 'number' || parsed.expiresAt <= Date.now()) return undefined; - return parsed; + const parsed = parseRepairTombstone(raw, tombstonePath); + return parsed.expiresAt > Date.now() ? parsed : undefined; +} + +function parseRepairTombstone(raw: string, tombstonePath: string): RepairSessionTombstone { + try { + const parsed = JSON.parse(raw) as RepairSessionTombstone; + if ( + !Number.isFinite(parsed?.expiresAt) || + typeof parsed?.owner !== 'string' || + !validRepairCommitFailure(parsed.commitFailure) + ) + throw new Error('Invalid repair tombstone fields'); + return parsed; + } catch (error) { + throw new AppError( + 'COMMAND_FAILED', + 'Repair evidence could not be inspected.', + { reason: 'repair_evidence_invalid', path: tombstonePath }, + error instanceof Error ? error : undefined, + ); + } +} + +function validRepairCommitFailure(value: unknown): boolean { + const failure = value as RepairSessionTombstone['commitFailure'] | null; + return ( + value === undefined || + (typeof failure?.code === 'string' && typeof failure?.message === 'string') + ); } /** @@ -54,6 +86,7 @@ export function readRepairTombstoneFile(tombstonePath: string): RepairSessionTom * CLIENT side of the daemon boundary (`cleanupDaemonAfterRequest` in * `daemon-client-lifecycle.ts`), which has no live `SessionStore`/session name * to key off of, only the filesystem path an owned ephemeral daemon was given. + * Unreadable or malformed evidence throws so cleanup retains the directory. * An owned ephemeral state dir services exactly one repair transaction at a * time, so the first match found is returned. * @@ -71,12 +104,13 @@ export function findUnrecoveredRepairCommitFailure(sessionsDir: string): let entries: fs.Dirent[]; try { entries = fs.readdirSync(sessionsDir, { withFileTypes: true }); - } catch { - return undefined; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; } for (const entry of entries) { if (!entry.isDirectory()) continue; - const tombstone = readRepairTombstoneFile( + const tombstone = readRepairTombstoneForCleanup( resolveRepairTombstonePath(path.join(sessionsDir, entry.name)), ); if (tombstone?.commitFailure) { diff --git a/test/integration/support/daemon-test-cleanup.test.ts b/test/integration/support/daemon-test-cleanup.test.ts new file mode 100644 index 0000000000..baba38e0a6 --- /dev/null +++ b/test/integration/support/daemon-test-cleanup.test.ts @@ -0,0 +1,145 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { test, vi } from 'vitest'; +import { + isProcessAlive, + readHostProcessIdentityObservations, +} from '@agent-device/host-kit/process'; +import { cleanupDaemonTestState } from './daemon-test-cleanup.ts'; +import { resolveDaemonPaths } from '../../../src/daemon-resolution.ts'; +import { stopDaemonProcess } from '../../../src/daemon-process.ts'; +import { mkdtempForTestSync } from '../../../src/__tests__/test-utils/tmp-dir.ts'; +import { + spawnRegisteredDaemonFixture, + waitForRegisteredDaemonFixture, + finishRegisteredDaemonFixture, +} from '../../../src/__tests__/test-utils/registered-daemon-fixture.ts'; + +const fields = { + httpPort: 4210, + token: 'fixture', + version: 'test', + codeOrigin: 'checkout' as const, + codeSignature: 'fixture', +}; + +test.each(['string', 'out-of-range'])( + 'malformed %s registration retains the directory and live daemon', + async (kind) => { + const paths = resolveDaemonPaths(mkdtempForTestSync('daemon-test-invalid-registration-')); + const child = spawnRegisteredDaemonFixture(paths, fields, undefined); + const warnings = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const info = await waitForRegisteredDaemonFixture(paths, child); + const malformed = JSON.stringify({ + ...info, + pid: kind === 'string' ? String(info.pid) : 2_147_483_648, + }); + fs.writeFileSync(paths.infoPath, malformed); + await cleanupDaemonTestState(paths.baseDir, null); + assert.equal(fs.readFileSync(paths.infoPath, 'utf8'), malformed); + assert.equal(isProcessAlive(child.pid), true); + assert.equal(warnings.mock.calls.length, 1); + } finally { + warnings.mockRestore(); + await finishRegisteredDaemonFixture(paths.baseDir); + } + }, +); + +test.each([false, true])( + 'cleanup joins a registered successor when the old observation lacks birth proof: %s', + async (missingBirth) => { + const paths = resolveDaemonPaths(mkdtempForTestSync('daemon-test-replaced-registration-')); + const original = spawnRegisteredDaemonFixture(paths, fields, undefined); + try { + const observed = await waitForRegisteredDaemonFixture(paths, original); + const stopped = await stopDaemonProcess( + { pid: original.pid, startTime: observed.processStartTime ?? null }, + { mode: 'force', termTimeoutMs: 0, killTimeoutMs: 1_000 }, + ); + assert.equal(stopped.status, 'exited'); + await original.exited; + const replacement = spawnRegisteredDaemonFixture(paths, fields, undefined); + await waitForRegisteredDaemonFixture(paths, replacement); + await cleanupDaemonTestState(paths.baseDir, { + ...observed, + processStartTime: missingBirth ? undefined : observed.processStartTime, + }); + assert.ok( + !isProcessAlive(replacement.pid) || + readHostProcessIdentityObservations([replacement.pid]) + .get(replacement.pid) + ?.state.startsWith('Z'), + 'the registered replacement must be dead before cleanup returns', + ); + await replacement.exited; + assert.equal(isProcessAlive(replacement.pid), false); + assert.equal(fs.existsSync(paths.baseDir), false); + } finally { + await finishRegisteredDaemonFixture(paths.baseDir); + } + }, +); + +test.each(['invalid-json', 'ownerless'])( + 'cleanup joins its observed child and retains %s metadata', + async (kind) => { + const paths = resolveDaemonPaths(mkdtempForTestSync('daemon-test-corrupt-observed-')); + const child = spawnRegisteredDaemonFixture(paths, fields, undefined); + const warnings = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const observed = await waitForRegisteredDaemonFixture(paths, child); + const corrupt = kind === 'invalid-json' ? '{invalid' : '{"pid": "unknown"}'; + fs.writeFileSync(paths.infoPath, corrupt); + await cleanupDaemonTestState(paths.baseDir, observed); + assert.ok( + !isProcessAlive(child.pid) || + readHostProcessIdentityObservations([child.pid]).get(child.pid)?.state.startsWith('Z'), + 'the observed child must be terminated before cleanup returns', + ); + await child.exited; + assert.equal(isProcessAlive(child.pid), false); + assert.equal(fs.readFileSync(paths.infoPath, 'utf8'), corrupt); + assert.equal(warnings.mock.calls.length, 1); + } finally { + warnings.mockRestore(); + await finishRegisteredDaemonFixture(paths.baseDir); + } + }, +); + +test('cleanup retains an unpublished successor holding the registration lock', async () => { + const paths = resolveDaemonPaths(mkdtempForTestSync('daemon-test-unpublished-successor-')); + const original = spawnRegisteredDaemonFixture(paths, fields, undefined); + const warnings = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const observed = await waitForRegisteredDaemonFixture(paths, original); + assert.equal( + ( + await stopDaemonProcess( + { pid: original.pid, startTime: observed.processStartTime ?? null }, + { mode: 'force', termTimeoutMs: 0, killTimeoutMs: 1_000 }, + ) + ).status, + 'exited', + ); + await original.exited; + fs.rmSync(paths.infoPath); + fs.rmSync(paths.baseDir + '/registration-held'); + fs.writeFileSync(paths.baseDir + '/defer-publication', 'wait'); + const successor = spawnRegisteredDaemonFixture(paths, fields, undefined); + await vi.waitFor( + () => assert.equal(fs.existsSync(paths.baseDir + '/registration-held'), true), + { timeout: 4_000, interval: 10 }, + ); + await cleanupDaemonTestState(paths.baseDir, observed); + assert.equal(fs.existsSync(paths.baseDir), true); + assert.equal(fs.existsSync(paths.infoPath), false); + assert.equal(isProcessAlive(successor.pid), true); + assert.equal(warnings.mock.calls.length, 1); + } finally { + warnings.mockRestore(); + await finishRegisteredDaemonFixture(paths.baseDir); + } +}); diff --git a/test/integration/support/daemon-test-cleanup.ts b/test/integration/support/daemon-test-cleanup.ts index 371159c521..aa5c64fe75 100644 --- a/test/integration/support/daemon-test-cleanup.ts +++ b/test/integration/support/daemon-test-cleanup.ts @@ -1,7 +1,17 @@ import fs from 'node:fs'; -import path from 'node:path'; import { normalizeError } from '@agent-device/kernel/errors'; +import { tryAcquireProcessLock } from '@agent-device/host-kit/file'; +import { + readCurrentOwnerIdentity, + ownerIdentityMatches, + type OwnerIdentity, +} from '@agent-device/host-kit/process'; import { stopDaemonProcess } from '../../../src/daemon-process.ts'; +import { resolveDaemonPaths } from '../../../src/daemon-resolution.ts'; +import { + readRegisteredDaemonIdentity, + readRegisteredDaemonOwnership, +} from '../../../src/daemon-registration.ts'; type TestDaemonIdentity = { pid: number; processStartTime?: string }; @@ -11,29 +21,57 @@ export async function cleanupDaemonTestState( observed: TestDaemonIdentity | null, ): Promise { try { - const identity = observed ?? readIdentity(stateDir); - if (!identity) throw new Error('No daemon lifetime was observed'); - const termination = await stopDaemonProcess( - { pid: identity.pid, startTime: identity.processStartTime ?? null }, - { mode: 'graceful', termTimeoutMs: 1_500, killTimeoutMs: 1_500 }, - ); - if (termination.status !== 'exited') { - console.warn('Daemon test cleanup retained state:', stateDir, termination); - return; + const paths = resolveDaemonPaths(stateDir); + const identities: OwnerIdentity[] = observed + ? [{ pid: observed.pid, startTime: observed.processStartTime ?? null }] + : []; + let registrationFailure: unknown; + try { + const registered = readIdentity(paths.infoPath); + if (registered) identities.push(registered); + } catch (error) { + registrationFailure = error; + } + const confirmed: OwnerIdentity[] = []; + let retained = false; + for (const identity of identities) { + const termination = await stopDaemonProcess(identity, { + mode: 'graceful', + termTimeoutMs: 1_500, + killTimeoutMs: 1_500, + }); + if (termination.status === 'exited') confirmed.push(identity); + else if (termination.status === 'retained') retained = true; + } + if (registrationFailure) throw registrationFailure; + if (retained || confirmed.length === 0) + throw new Error('Daemon termination could not be confirmed'); + const attempt = tryAcquireProcessLock({ + lockDirPath: paths.lockPath, + owner: { ...readCurrentOwnerIdentity(), acquiredAtMs: Date.now() }, + description: 'daemon test cleanup', + }); + if (attempt.status !== 'acquired') + throw new Error('Daemon registration is held or unproven during test cleanup'); + const { acquisition } = attempt; + try { + acquisition.assertHeld(); + const current = readIdentity(paths.infoPath); + if (current && !confirmed.some((identity) => ownerIdentityMatches(identity, current))) + throw new Error('Daemon registration changed during test cleanup'); + acquisition.assertHeld(); + fs.rmSync(stateDir, { recursive: true, force: true }); + } finally { + await acquisition.release(); } - fs.rmSync(stateDir, { recursive: true, force: true }); } catch (error) { console.warn('Daemon test cleanup retained state:', stateDir, normalizeError(error)); } } -function readIdentity(stateDir: string): TestDaemonIdentity | null { - try { - return JSON.parse( - fs.readFileSync(path.join(stateDir, 'daemon.json'), 'utf8'), - ) as TestDaemonIdentity; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; - throw error; - } +function readIdentity(infoPath: string): OwnerIdentity | null { + if (readRegisteredDaemonOwnership(infoPath, null).state === 'absent') return null; + const identity = readRegisteredDaemonIdentity(infoPath); + if (!identity) throw new Error('Daemon registration identity is invalid or unreadable'); + return identity; } diff --git a/test/wire-compat/ledger.json b/test/wire-compat/ledger.json index 80e13dc3e9..20fc434604 100644 --- a/test/wire-compat/ledger.json +++ b/test/wire-compat/ledger.json @@ -67,7 +67,7 @@ "src/daemon-client/daemon-client-rpc.ts#toDaemonHttpRpcError": "sha256:888246763c48670e7da893054d025744654f8715c3b4906312617a2b5028316b", "src/daemon-client/daemon-client-transport.ts#RemoteDaemonHealth": "sha256:3bac36fa97090b273afe1128103e1bf476d05441fd9de41317f1b78775b88304", "src/daemon-client/daemon-client-transport.ts#RemoteDaemonHealthLink": "sha256:7702598468b4c82b4ffa93064cd6bdfec938c1c527f7e6007c0584f3884a6eea", - "src/daemon-client/daemon-client-transport.ts#readDaemonHttpHealth": "sha256:eef0e153eb6f0bc02175e464dd6d98559b500b65ea8c74ba2203cfef09ec09a0", + "src/daemon-client/daemon-client-transport.ts#readDaemonHttpHealth": "sha256:f40cc2f5bfb8add78f99b11db7842bc244735786aa390d10a38ef025e16dc53a", "src/daemon-client/daemon-client-transport.ts#readHealthLink": "sha256:f56404b94d73da57de7248719c9136b760d2be8b4a53cde5315a2311b088425b", "src/daemon-client/daemon-client-transport.ts#readHealthPayload": "sha256:4e85ffc3e35e02379c393e9312344757e003cf1f0ad9eb8d1f77d90c81c861f1", "src/daemon-client/daemon-client-transport.ts#readRemoteDaemonHealth": "sha256:bcefa89fbb7fcbd6fee1b5ecb217955b9eee8d6fd2ad175fb653011edbd199d9", @@ -431,8 +431,8 @@ }, { "declaration": "src/daemon-client/daemon-client-transport.ts#readDaemonHttpHealth", - "digest": "sha256:eef0e153eb6f0bc02175e464dd6d98559b500b65ea8c74ba2203cfef09ec09a0", - "rationale": "A restart retry must tell a probe that ran out of time from one that failed, so the client can report the RPC deadline instead of 'Remote daemon is unavailable'. `timedOut` is client-local: the prober sets it on its own timeout and abort paths and never reads it from a /health payload. The health request and the accepted payload fields are unchanged, so a released daemon or proxy is probed and parsed exactly as before." + "digest": "sha256:f40cc2f5bfb8add78f99b11db7842bc244735786aa390d10a38ef025e16dc53a", + "rationale": "#3116 includes requester loading and response completion in the existing health-probe budget. `timedOut` remains client-local; it is never read from a /health payload. Request routes, authentication, accepted fields and protocol-version admission are unchanged, so released daemons and proxies still send payloads this client parses correctly. This is an implementation-only timing change under ADR 0006." } ] } diff --git a/vitest.config.ts b/vitest.config.ts index 1b4390fc19..33237c9ecf 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -178,6 +178,8 @@ export default defineConfig({ // decisions over fixture state-dir listings, so they need no daemon, // device, or subprocess. 'test/integration/support/daemon-leak-model.test.ts', + // Cleanup uses real detached registration owners and process-exit proof; no device is needed. + 'test/integration/support/daemon-test-cleanup.test.ts', // The Android failed-step evidence reader: it replays adb output through the probe // seam, so the crash/process/activity selectors need no emulator to be pinned. 'test/integration/android-emulator-e2e/device-evidence.test.ts', diff --git a/website/docs/docs/installation.md b/website/docs/docs/installation.md index eca4ff398d..cef44e2760 100644 --- a/website/docs/docs/installation.md +++ b/website/docs/docs/installation.md @@ -115,10 +115,15 @@ vega device list - A runner startup failure is typed, not prose: `error.details.reason` is one of `signing_no_development_team`, `signing_provisioning_profile_missing`, `bundle_identifier_already_registered`, `signing_unspecified`, `devtools_security_developer_mode_disabled` (the Mac's `DevToolsSecurity` setting, which says nothing about the device's Developer Mode toggle), `device_developer_mode_disabled`, `device_developer_disk_image_unavailable`, or `build_failed_unclassified` when nothing proved a cause. Branch on `details.reason` and follow `hint`; the code stays `COMMAND_FAILED` for every reason. - The two `device_*` reasons come from the iPhone itself, read over `xcrun devicectl device info details` before the runner builds: `developerModeStatus` for the Settings toggle and `ddiServicesAvailable` for the developer disk image. They are reported apart on purpose. A phone with Developer Mode off cannot serve its disk image either, so it gets the toggle reason; a phone with the toggle on and only the image down gets the disk-image reason, which is a device-support install that has not finished rather than a setting anyone turned off. - If device setup is slow, keep the device connected and inspect daemon diagnostics after retrying. -- If daemon startup reports stale metadata, remove stale files and retry: - - `/daemon.json` - - `/daemon.lock` - - default state dir is `~/.agent-device` for packaged installs; source checkouts default to a worktree-scoped dir under `~/.agent-device/dev/` unless `AGENT_DEVICE_STATE_DIR` or `--state-dir` is set - - `agent-device session state-dir` prints the resolved state dir without starting the daemon - - after pulling the worktree-scoped daemon change in a source checkout, stop any legacy default daemon once with `AGENT_DEVICE_STATE_DIR=~/.agent-device pnpm clean:daemon` - - worktree-scoped state dirs outlive deleted worktrees; `pnpm clean:daemon --prune-dev` removes dirs under `~/.agent-device/dev/` with no live daemon and no activity for 14 days (one line printed per removed dir) + +## Daemon startup and upgrades + +If daemon startup fails, retry with `--debug` and inspect the retained state and diagnostics. `agent-device session state-dir` prints the resolved directory without starting a daemon. + +Before upgrading across the daemon lock change, stop every older client and daemon using that directory with their original CLI. Prevent older versions from returning while the upgraded version runs. Use a single deployed version or separate environments for concurrent installations. + +Startup refuses legacy lock files and unverified ownership. Confirm every user of the state directory stopped before manual recovery; removing `daemon.json` or `daemon.lock` alone is not a safe reset. + +Packaged installs default to `~/.agent-device`; source checkouts use a worktree directory under `~/.agent-device/dev/`. `AGENT_DEVICE_STATE_DIR` or `--state-dir` overrides either default. + +For source checkouts, `pnpm clean:daemon --prune-dev` selects development directories with no activity for 14 days, using the newest mtime of the directory and its immediate children. It retires only registration it can confirm abandoned. Directories, session artifacts and logs remain.