From a4efad510b4df439f397765769ce2f860c1571e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 05:17:29 +0200 Subject: [PATCH 01/44] refactor: resolve session artifact paths without a store --- packages/host-kit/src/session-paths.test.ts | 19 +++++++++ .../__tests__/session-artifact-paths.test.ts | 29 ++++++++++++++ src/daemon/__tests__/session-store.test.ts | 40 ------------------- .../device/device-claim-owner-recovery.ts | 13 +++--- src/daemon/handlers/session-app-deployment.ts | 7 ++-- src/daemon/handlers/trace-runtime.ts | 9 ++--- src/daemon/screenshot-runtime.ts | 4 +- src/daemon/session-artifact-paths.ts | 27 ++++++++++++- .../internal/session-perf-runtime.ts | 11 ++--- src/daemon/session-store.ts | 32 ++++----------- 10 files changed, 106 insertions(+), 85 deletions(-) create mode 100644 packages/host-kit/src/session-paths.test.ts create mode 100644 src/daemon/__tests__/session-artifact-paths.test.ts 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..be0c8d3116 --- /dev/null +++ b/packages/host-kit/src/session-paths.test.ts @@ -0,0 +1,19 @@ +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.startsWith(os.homedir()), true); + assert.equal(homePath.endsWith(path.join('flows', 'replay.ad')), true); + + 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/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-store.test.ts b/src/daemon/__tests__/session-store.test.ts index 05fe386324..5eddff721d 100644 --- a/src/daemon/__tests__/session-store.test.ts +++ b/src/daemon/__tests__/session-store.test.ts @@ -1,10 +1,7 @@ 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'; @@ -96,19 +93,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 +103,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 = { 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/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/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/screenshot-runtime.ts b/src/daemon/screenshot-runtime.ts index f0e4c76477..f10f44ab42 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, @@ -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'; @@ -321,7 +321,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/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-observability/internal/session-perf-runtime.ts b/src/daemon/session-observability/internal/session-perf-runtime.ts index 210528eea6..cb6acdb0a7 100644 --- a/src/daemon/session-observability/internal/session-perf-runtime.ts +++ b/src/daemon/session-observability/internal/session-perf-runtime.ts @@ -1,3 +1,4 @@ +import { expandSessionPath } from '@agent-device/host-kit/session-paths'; import path from 'node:path'; import { type PerfCaptureAdmissionLedger } from '@agent-device/capture-kit/perf-capture-admission-ledger'; import { @@ -28,7 +29,7 @@ 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 { recordSessionAction } from '../../session-action-recorder.ts'; @@ -138,7 +139,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 +172,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, @@ -257,7 +258,7 @@ 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', @@ -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-store.ts b/src/daemon/session-store.ts index 1598dd9414..e31c66403a 100644 --- a/src/daemon/session-store.ts +++ b/src/daemon/session-store.ts @@ -1,14 +1,14 @@ import path from 'node:path'; import fs from 'node:fs'; -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, @@ -380,20 +380,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,21 +399,17 @@ 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. From 3b309f0dda0b3b880374cb8f91938dee46b08c3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 00:26:52 +0200 Subject: [PATCH 02/44] fix: bind replay directory cleanup to joined daemon startups --- .../daemon-registration-owner.test.ts | 156 +++++++++++- .../test-utils/registered-daemon-fixture.ts | 84 +++++++ .../__tests__/daemon-client-lifecycle.test.ts | 62 ++--- src/daemon-client/daemon-client-lifecycle.ts | 137 +++++------ src/daemon-registration-owner.ts | 227 ++++++++++++++++-- src/session-repair-tombstone.ts | 56 ++++- 6 files changed, 586 insertions(+), 136 deletions(-) create mode 100644 src/__tests__/test-utils/registered-daemon-fixture.ts diff --git a/src/__tests__/daemon-registration-owner.test.ts b/src/__tests__/daemon-registration-owner.test.ts index edb42abced..f5f67ac699 100644 --- a/src/__tests__/daemon-registration-owner.test.ts +++ b/src/__tests__/daemon-registration-owner.test.ts @@ -1,16 +1,22 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; 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, + 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 } from './test-utils/registered-daemon-fixture.ts'; +import { sleep } from '@agent-device/host-kit/retry'; +import { stopDaemonProcess } from '../daemon-process.ts'; const fields = { socketPort: 4210, @@ -305,3 +311,151 @@ 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 }); + try { + assert.ok(launch.startTime); + await waitForFixtureFile(paths.infoPath); + 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); + } +}); + +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', + ...[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 }); +} 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..63ee16a7bc --- /dev/null +++ b/src/__tests__/test-utils/registered-daemon-fixture.ts @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { vi } from 'vitest'; +import type { 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'; +import type { DaemonRegistrationFields } from '../../daemon-registration-owner.ts'; + +const actualCommand = await vi.importActual( + '@agent-device/host-kit/command', +); +const children = new Map< + string, + { 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, +): 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 { tryAcquireDaemonRegistration } from ${JSON.stringify(registrationUrl)}; +const paths = ${JSON.stringify(paths)}; +const acquired = await tryAcquireDaemonRegistration(paths); +if (acquired.status !== 'acquired') process.exit(75); +process.on('SIGTERM', async () => { + 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); +}); +acquired.owner.publish(${JSON.stringify(fields)}); +setInterval(() => {}, 1000); +`, + ); + return ['--experimental-strip-types', entry]; +} + +export function spawnRegisteredDaemonFixture( + paths: DaemonPaths, + fields: DaemonRegistrationFields, + options: Parameters[2], +): ReturnType { + const child = actualCommand.runCmdDetachedMonitored( + process.execPath, + registeredDaemonFixtureArgs(paths, fields), + options, + ); + children.set(paths.baseDir, { launch: child, startTime: readProcessStartTime(child.pid) }); + return child; +} + +export async function finishRegisteredDaemonFixture(stateDir: string): Promise { + const owned = children.get(stateDir); + if (owned) { + 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/daemon-client/__tests__/daemon-client-lifecycle.test.ts b/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts index 91f4da210a..b27673099b 100644 --- a/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts +++ b/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts @@ -6,6 +6,11 @@ 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, + finishRegisteredDaemonFixture, + finishRegisteredDaemonFixtures, +} from '../../__tests__/test-utils/registered-daemon-fixture.ts'; vi.mock('@agent-device/host-kit/command', async (importOriginal) => ({ ...(await importOriginal()), @@ -51,14 +56,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 +157,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, + ); }); } @@ -813,7 +829,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); } }); @@ -854,7 +870,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); } }); @@ -888,7 +904,7 @@ test('sendToDaemon tears down an owned ephemeral daemon on an UNHELD divergence 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); } }); @@ -916,16 +932,8 @@ test('BLOCKER 2 (third follow-up): a shutdown-time repair commit failure is surf 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'), + path.join(dir, 'repair-on-shutdown.json'), `${JSON.stringify({ owner: 'default', reapedAt: Date.now(), @@ -968,7 +976,7 @@ test('BLOCKER 2 (third follow-up): a shutdown-time repair commit failure is surf ); } finally { await closeLoopbackServer(daemon.server); - if (ownedStateDir) fs.rmSync(ownedStateDir, { recursive: true, force: true }); + if (ownedStateDir) await finishRegisteredDaemonFixture(ownedStateDir); } }); @@ -1003,7 +1011,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 +1139,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 +1178,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 +1210,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 +1256,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); } }); @@ -1328,6 +1336,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/daemon-client-lifecycle.ts b/src/daemon-client/daemon-client-lifecycle.ts index 43a6556781..03cfa395f5 100644 --- a/src/daemon-client/daemon-client-lifecycle.ts +++ b/src/daemon-client/daemon-client-lifecycle.ts @@ -1,17 +1,22 @@ 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 { 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 { findUnrecoveredRepairCommitFailure } from '../session-repair-tombstone.ts'; +import type { findUnrecoveredRepairCommitFailure } from '../session-repair-tombstone.ts'; +import { + createOwnedReplayStateDir, + launchDaemonProcess, + stopAndRetireDaemon, + type OwnedReplayStateDir, + type DaemonStartupLaunch, +} from '../daemon-registration-owner.ts'; import { resolveDaemonPaths, resolveDaemonServerMode, @@ -36,7 +41,6 @@ import { readDaemonInfo, recoverDaemonLockHolder, removeDaemonInfo, - removeDaemonLock, resolveDaemonStartupHint, stopDaemonProcessForTakeover, type DaemonInfo, @@ -52,7 +56,7 @@ export type DaemonClientSettings = { paths: DaemonPaths; transportPreference: DaemonTransportPreference; serverMode: DaemonServerMode; - ownedStateDir?: boolean; + ownedStateDir?: OwnedReplayStateDir; remoteBaseUrl?: string; remoteAuthToken?: string; }; @@ -62,13 +66,6 @@ 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 } @@ -91,10 +88,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 +151,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); @@ -435,49 +429,40 @@ 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 === 'retired' && result.repairCommitFailure) { + return surfaceUnrecoveredRepairCommitFailure(response, result.repairCommitFailure); + } + 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; } /** @@ -669,28 +654,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 { diff --git a/src/daemon-registration-owner.ts b/src/daemon-registration-owner.ts index 0092f087af..7b95a0c52c 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; @@ -171,14 +272,22 @@ export type DaemonRetirementInput = Readonly<{ type ConfirmedDaemonTermination = Extract; export type DaemonRetirementResult = - | Readonly<{ status: 'retired'; termination: ConfirmedDaemonTermination; removedInfo: boolean }> + | Readonly<{ + status: 'retired'; + termination: ConfirmedDaemonTermination; + removedInfo: boolean; + removedStateDir?: boolean; + repairCommitFailure?: NonNullable>; + }> | 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' @@ -190,15 +299,55 @@ export type DaemonRetirementResult = /** 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.at(-1)?.launch; + if ( + !launch?.startTime || + launch.pid !== input.observed?.pid || + launch.startTime !== input.observed.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 +366,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 +392,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 +423,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 +435,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 +499,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/session-repair-tombstone.ts b/src/session-repair-tombstone.ts index 59c2b60a9f..293b1ada4d 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 ( + typeof parsed?.expiresAt !== 'number' || + 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) { From a0ea35d211f31221ed41b3bbc0947e0a3a1ad672 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 01:08:46 +0200 Subject: [PATCH 03/44] fix: preserve repair outcomes through partial replay retirement --- .../daemon-registration-owner.test.ts | 8 +- .../__tests__/daemon-client-lifecycle.test.ts | 119 ++++++++---------- src/daemon-client/daemon-client-lifecycle.ts | 22 +++- src/daemon-registration-owner.ts | 15 +-- src/session-repair-tombstone.ts | 2 +- 5 files changed, 89 insertions(+), 77 deletions(-) diff --git a/src/__tests__/daemon-registration-owner.test.ts b/src/__tests__/daemon-registration-owner.test.ts index f5f67ac699..7f4795f6d8 100644 --- a/src/__tests__/daemon-registration-owner.test.ts +++ b/src/__tests__/daemon-registration-owner.test.ts @@ -7,6 +7,7 @@ import { stopAndRetireDaemon, recoverAbandonedDaemonRegistration, createOwnedReplayStateDir, + DAEMON_STARTUP_EXIT_CODES, launchDaemonProcess, type OwnedReplayStateDir, } from '../daemon-registration-owner.ts'; @@ -331,9 +332,12 @@ test('private retirement closes startup admission, joins the actual child and ne 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 }, @@ -355,7 +359,7 @@ test('private retirement closes startup admission, joins the actual child and ne assert.deepEqual(await stopAndRetireDaemon(input), result); assert.equal(fs.existsSync(paths.baseDir), false); } finally { - await finishPrivateTestDaemons(paths, launch); + await finishPrivateTestDaemons(paths, launch, contender); } }); @@ -401,6 +405,8 @@ test('private retirement retains the directory while an earlier actual startup c 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 }), ), diff --git a/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts b/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts index b27673099b..1ba1ac1f72 100644 --- a/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts +++ b/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts @@ -532,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); @@ -754,9 +753,6 @@ test('sendToDaemon does not replay over HTTP after the socket request is written } }); -// --- 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. --- @@ -908,75 +904,70 @@ test('sendToDaemon tears down an owned ephemeral daemon on an UNHELD divergence } }); -// --- 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; - 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`, - ); - }); - - 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) await finishRegisteredDaemonFixture(ownedStateDir); + assert.equal(response.ok, false); + if (response.ok) return; + assert.equal(response.error.code, 'REPAIR_COMMIT_FAILED'); + const secondary = response.error.details?.cleanupFailure as + | { details?: { ownerReleaseUnverified?: boolean }; hint?: string } + | undefined; + assert.equal(secondary?.details?.ownerReleaseUnverified, failRelease ? true : undefined); + if (failRelease) assert.match(secondary?.hint ?? '', /Restore process inspection/); + 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); + } } }); diff --git a/src/daemon-client/daemon-client-lifecycle.ts b/src/daemon-client/daemon-client-lifecycle.ts index 03cfa395f5..c3d0508745 100644 --- a/src/daemon-client/daemon-client-lifecycle.ts +++ b/src/daemon-client/daemon-client-lifecycle.ts @@ -1,6 +1,6 @@ import fs from 'node:fs'; import net from 'node:net'; -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 { type ExecDetachedExit } from '@agent-device/host-kit/command'; @@ -440,8 +440,12 @@ export async function cleanupDaemonAfterRequest( phase: 'daemon_replay_cleanup', data: { pid: daemon.info.pid, ...result }, }); - if (result.status === 'retired' && result.repairCommitFailure) { - return surfaceUnrecoveredRepairCommitFailure(response, result.repairCommitFailure); + if (result.status !== 'absent' && result.repairCommitFailure) { + return surfaceUnrecoveredRepairCommitFailure( + response, + result.repairCommitFailure, + result.status === 'retained' ? result.error : undefined, + ); } if (result.status === 'retained' && response?.ok) { return { @@ -483,6 +487,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; @@ -492,7 +497,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, + ), + ), + }; } /** diff --git a/src/daemon-registration-owner.ts b/src/daemon-registration-owner.ts index 7b95a0c52c..d2afde67bb 100644 --- a/src/daemon-registration-owner.ts +++ b/src/daemon-registration-owner.ts @@ -270,6 +270,7 @@ export type DaemonRetirementInput = Readonly<{ lockTimeoutMs?: number; }>; +type RepairCommitFailure = NonNullable>; type ConfirmedDaemonTermination = Extract; export type DaemonRetirementResult = | Readonly<{ @@ -277,7 +278,7 @@ export type DaemonRetirementResult = termination: ConfirmedDaemonTermination; removedInfo: boolean; removedStateDir?: boolean; - repairCommitFailure?: NonNullable>; + repairCommitFailure?: RepairCommitFailure; }> | Readonly<{ status: 'absent'; removedInfo: false }> | Readonly<{ @@ -295,6 +296,7 @@ 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. */ @@ -311,12 +313,11 @@ export async function stopAndRetireDaemon( if (input.ownedStateDir) { owned = requirePrivateReplayState(input.ownedStateDir, input.paths); owned.sealed = true; - const launch = owned.startups.at(-1)?.launch; - if ( - !launch?.startTime || - launch.pid !== input.observed?.pid || - launch.startTime !== input.observed.startTime - ) + 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.', diff --git a/src/session-repair-tombstone.ts b/src/session-repair-tombstone.ts index 293b1ada4d..a0fae0461b 100644 --- a/src/session-repair-tombstone.ts +++ b/src/session-repair-tombstone.ts @@ -55,7 +55,7 @@ function parseRepairTombstone(raw: string, tombstonePath: string): RepairSession try { const parsed = JSON.parse(raw) as RepairSessionTombstone; if ( - typeof parsed?.expiresAt !== 'number' || + !Number.isFinite(parsed?.expiresAt) || typeof parsed?.owner !== 'string' || !validRepairCommitFailure(parsed.commitFailure) ) From ecb5472b90891457c9edc3ede03655a8960c2547 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 01:14:21 +0200 Subject: [PATCH 04/44] test: simplify paired repair cleanup assertions --- .../__tests__/daemon-client-lifecycle.test.ts | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts b/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts index 1ba1ac1f72..89c63cb186 100644 --- a/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts +++ b/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts @@ -753,9 +753,6 @@ test('sendToDaemon does not replay over HTTP after the socket request is written } }); -// 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 { @@ -817,8 +814,6 @@ 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); @@ -835,8 +830,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, @@ -895,8 +888,6 @@ 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); @@ -945,14 +936,13 @@ test('BLOCKER 2 (third follow-up): a shutdown-time repair commit failure is surf meta: { requestId: 'req-repair-commit-fail-teardown' }, }); - assert.equal(response.ok, false); - if (response.ok) return; + 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(secondary?.details?.ownerReleaseUnverified, failRelease ? true : undefined); - if (failRelease) assert.match(secondary?.hint ?? '', /Restore process inspection/); + 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')); From 2a359db702738041ae8da20fa4de81ae45769b5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 02:56:47 +0200 Subject: [PATCH 05/44] fix: keep daemon lifecycle outside the CLI import closure --- src/daemon-client/daemon-client-lifecycle.ts | 24 +++++++- src/daemon-client/daemon-client.ts | 63 ++------------------ 2 files changed, 26 insertions(+), 61 deletions(-) diff --git a/src/daemon-client/daemon-client-lifecycle.ts b/src/daemon-client/daemon-client-lifecycle.ts index c3d0508745..62495a82a8 100644 --- a/src/daemon-client/daemon-client-lifecycle.ts +++ b/src/daemon-client/daemon-client-lifecycle.ts @@ -523,7 +523,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); @@ -538,7 +538,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 { @@ -570,7 +570,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 { @@ -756,3 +756,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.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( From 3e5be9adbce738b24a465ac6f34ac86d6c347bbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 01:44:41 +0200 Subject: [PATCH 06/44] fix(daemon): retire only the captured failed startup --- .../__tests__/daemon-client-lifecycle.test.ts | 32 ++- .../__tests__/daemon-client-metadata.test.ts | 18 +- .../daemon-client-startup-race.test.ts | 2 +- .../__tests__/daemon-client.test.ts | 183 +----------------- src/daemon-client/daemon-client-lifecycle.ts | 130 +++++++------ src/daemon-client/daemon-client-metadata.ts | 101 ---------- 6 files changed, 93 insertions(+), 373 deletions(-) diff --git a/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts b/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts index 89c63cb186..d4d5ae9880 100644 --- a/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts +++ b/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts @@ -314,7 +314,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); @@ -345,25 +345,17 @@ 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 }); } diff --git a/src/daemon-client/__tests__/daemon-client-metadata.test.ts b/src/daemon-client/__tests__/daemon-client-metadata.test.ts index 5b46a91e8d..6bfec46b6e 100644 --- a/src/daemon-client/__tests__/daemon-client-metadata.test.ts +++ b/src/daemon-client/__tests__/daemon-client-metadata.test.ts @@ -5,10 +5,12 @@ import path from 'node:path'; import { afterEach, test, vi } 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 { + stopAndRetireDaemon, + tryAcquireDaemonRegistration, +} from '../../daemon-registration-owner.ts'; import { readDaemonInfo, - cleanupFailedDaemonStartupMetadata, stopDaemonProcessForTakeover, type DaemonInfo, } from '../daemon-client-metadata.ts'; @@ -81,13 +83,15 @@ for (const artifact of ['daemon.json', 'daemon.lock']) { 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'); + const result = await stopAndRetireDaemon({ + paths, + observed: { pid: 7, startTime: 'start' }, + mode: 'graceful', + }); 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/); + assert.equal(result.status, 'retained'); + if (result.status === 'retained') assert.equal(result.reason, 'exit-unconfirmed'); }); } 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..fa87f3f65b 100644 --- a/src/daemon-client/__tests__/daemon-client-startup-race.test.ts +++ b/src/daemon-client/__tests__/daemon-client-startup-race.test.ts @@ -200,7 +200,7 @@ test('a start race won by an older daemon replaces it instead of adopting it', a }), ); assert.equal(fixture.rpcRequests.length, 0); - assert.equal(launches, 2); + assert.equal(launches, 1); assert.match( String(stderr.mock.calls.flat().join('')), /Replacing daemon \(pid 43300, v0\.0\.1\)/, diff --git a/src/daemon-client/__tests__/daemon-client.test.ts b/src/daemon-client/__tests__/daemon-client.test.ts index f579a3c4a5..accebf0782 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; @@ -219,144 +180,6 @@ test('resolveDaemonStartupHint shell-quotes cleanup paths', () => { ); }); -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 }); - } -}); - test('canConnectSocket times out stalled local daemon probes', async () => { const originalCreateConnection = net.createConnection; let timeoutMs: number | undefined; diff --git a/src/daemon-client/daemon-client-lifecycle.ts b/src/daemon-client/daemon-client-lifecycle.ts index 62495a82a8..7e75749e61 100644 --- a/src/daemon-client/daemon-client-lifecycle.ts +++ b/src/daemon-client/daemon-client-lifecycle.ts @@ -8,10 +8,13 @@ import { shellQuoteIfNeeded } from '@agent-device/kernel/device-shell'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { isProcessAlive } from '@agent-device/host-kit/process'; import { sleep } from '@agent-device/host-kit/retry'; +import { inspectProcessLock } from '@agent-device/host-kit/file'; import type { findUnrecoveredRepairCommitFailure } from '../session-repair-tombstone.ts'; import { createOwnedReplayStateDir, + recoverAbandonedDaemonRegistration, + type DaemonRetirementResult, launchDaemonProcess, stopAndRetireDaemon, type OwnedReplayStateDir, @@ -33,18 +36,15 @@ import { import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; import { - cleanupFailedDaemonStartupMetadata, cleanupStaleDaemonLockIfSafe, getDaemonMetadataState, isDaemonLockHeldByAnotherDaemon, isRemoteDaemon, readDaemonInfo, - recoverDaemonLockHolder, removeDaemonInfo, resolveDaemonStartupHint, stopDaemonProcessForTakeover, type DaemonInfo, - type DaemonStartupCleanupResult, } from './daemon-client-metadata.ts'; import { canConnect, @@ -297,65 +297,27 @@ 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; + 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; + cleanupResults.push(result.cleanup); + if (!result.retry) break; + await sleep(Math.min(150, Math.max(0, deadline - Date.now()))); } - const state = getDaemonMetadataState(settings.paths); const daemonLogTail = readRecentLogTail(settings.paths.logPath); throw new AppError('COMMAND_FAILED', 'Failed to start daemon', { @@ -365,17 +327,57 @@ 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(Math.max(0, deadline - Date.now()), settings, launch); + if (startup.kind === 'ready') return { daemon: startup.daemon }; + const cleanup = await stopAndRetireDaemon({ + paths: settings.paths, + observed: { pid: launch.pid, startTime: launch.startTime ?? null }, + mode: 'graceful', + lockTimeoutMs: 0, + }); + const inspection = inspectProcessLock(settings.paths.lockPath); + const available = + inspection.state === 'absent' || + (inspection.state === 'held' && + (inspection.liveness === 'owner-process-dead' || + inspection.liveness === 'owner-process-reused')); + return { + cleanup, + retry: startup.kind === 'early_exit' && available, + startError: startup.kind === 'early_exit' ? describeDaemonEarlyExit(startup.exit) : undefined, + daemonProcess: startup.kind === 'early_exit' ? startup.exit : { pid: launch.pid }, + }; +} + /** * ADR 0012 decision 6 (BLOCKER 2, third follow-up): a one-shot repair * (`replay --save-script`) that COMPLETES without diverging returns SUCCESS diff --git a/src/daemon-client/daemon-client-metadata.ts b/src/daemon-client/daemon-client-metadata.ts index 3a448b099f..bf886cef5c 100644 --- a/src/daemon-client/daemon-client-metadata.ts +++ b/src/daemon-client/daemon-client-metadata.ts @@ -1,7 +1,6 @@ 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, @@ -44,19 +43,6 @@ export type DaemonMetadataState = { 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; @@ -161,78 +147,6 @@ export function cleanupStaleDaemonLockIfSafe(paths: DaemonPaths): void { 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,21 +154,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 { From 5ec91d73ff0de87a99ebca8633fda15f1e04c26b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 02:44:53 +0200 Subject: [PATCH 07/44] fix: join contending daemon startups within one deadline --- .../test-utils/registered-daemon-fixture.ts | 17 +- .../__tests__/daemon-client-lifecycle.test.ts | 55 ++- .../daemon-client-startup-race.test.ts | 463 +++++++++++------- .../__tests__/daemon-client.test.ts | 50 +- src/daemon-client/daemon-client-lifecycle.ts | 225 +++++++-- src/daemon-client/daemon-client-metadata.ts | 78 +-- src/daemon-client/daemon-client-transport.ts | 24 +- website/docs/docs/installation.md | 19 +- 8 files changed, 560 insertions(+), 371 deletions(-) diff --git a/src/__tests__/test-utils/registered-daemon-fixture.ts b/src/__tests__/test-utils/registered-daemon-fixture.ts index 63ee16a7bc..12af382c10 100644 --- a/src/__tests__/test-utils/registered-daemon-fixture.ts +++ b/src/__tests__/test-utils/registered-daemon-fixture.ts @@ -13,7 +13,7 @@ const actualCommand = await vi.importActual; startTime: string | null } + Array<{ launch: ReturnType; startTime: string | null }> >(); /** A real registration owner, advertising the caller's HTTP fixture and joining before deletion. */ @@ -29,10 +29,10 @@ export function registeredDaemonFixtureArgs( entry, `import fs from 'node:fs'; import path from 'node:path'; -import { tryAcquireDaemonRegistration } from ${JSON.stringify(registrationUrl)}; +import { DAEMON_STARTUP_EXIT_CODES, tryAcquireDaemonRegistration } from ${JSON.stringify(registrationUrl)}; const paths = ${JSON.stringify(paths)}; const acquired = await tryAcquireDaemonRegistration(paths); -if (acquired.status !== 'acquired') process.exit(75); +if (acquired.status !== 'acquired') process.exit(DAEMON_STARTUP_EXIT_CODES[acquired.status]); process.on('SIGTERM', async () => { const deferred = path.join(paths.baseDir, 'repair-on-shutdown.json'); if (fs.existsSync(deferred)) { @@ -43,6 +43,8 @@ process.on('SIGTERM', async () => { 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); `, @@ -60,13 +62,14 @@ export function spawnRegisteredDaemonFixture( registeredDaemonFixtureArgs(paths, fields), options, ); - children.set(paths.baseDir, { launch: child, startTime: readProcessStartTime(child.pid) }); + const owned = children.get(paths.baseDir) ?? []; + owned.push({ launch: child, startTime: readProcessStartTime(child.pid) }); + children.set(paths.baseDir, owned); return child; } export async function finishRegisteredDaemonFixture(stateDir: string): Promise { - const owned = children.get(stateDir); - if (owned) { + for (const owned of children.get(stateDir) ?? []) { const child = owned.launch; const termination = await stopDaemonProcess( { pid: child.pid, startTime: owned.startTime }, @@ -74,8 +77,8 @@ export async function finishRegisteredDaemonFixture(stateDir: string): 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, + ); }); } @@ -357,7 +364,7 @@ test('sendToDaemon retains unknown metadata after a spawn failure', async () => assert.equal(results[0]?.removedInfo, false); assert.equal(attempts, 1); } finally { - fs.rmSync(stateDir, { recursive: true, force: true }); + await finishRegisteredDaemonFixture(stateDir); } }); @@ -402,11 +409,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; @@ -416,10 +423,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 { @@ -431,18 +439,15 @@ 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 { await closeLoopbackServer(daemon.server); - fs.rmSync(stateDir, { recursive: true, force: true }); + await finishRegisteredDaemonFixture(stateDir); } }); @@ -510,7 +515,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(); } } @@ -553,7 +558,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); } }); @@ -594,7 +599,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); } }); @@ -697,7 +702,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); } }); @@ -741,7 +746,7 @@ 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); } }); @@ -1275,7 +1280,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); } }); 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 fa87f3f65b..303a8b1faa 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,313 @@ 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')); + 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); + } }); +} - try { - const response = await sendToDaemon({ - session: 'default', - command: 'test', - positionals: [], - flags: { stateDir }, - meta: { requestId: 'req-start-race-test' }, +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 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 (!held && !released) { + await claim.acquisition.release(); + released = 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('a joined busy contender retires an older winner before relaunching', async (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')); + 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 }); + await actualRetry.sleep(ms); + }); + const notice = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + assert.equal((await sendToDaemon(request(paths))).ok, true); + await winner.exited; + assert.equal(isProcessAlive(winner.pid), false); + assert.equal(spawn.mock.calls.length, 2); + assert.equal(http.rpcRequests.length, 1); + assert.ok( + notice.mock.calls.flat().join('').includes(`Replacing daemon (pid ${winner.pid}, v0.0.1)`), + ); } finally { - await closeLoopbackServer(fixture.server); - fs.rmSync(stateDir, { recursive: true, force: true }); + 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, 1); - 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.test.ts b/src/daemon-client/__tests__/daemon-client.test.ts index accebf0782..cca31e8a26 100644 --- a/src/daemon-client/__tests__/daemon-client.test.ts +++ b/src/daemon-client/__tests__/daemon-client.test.ts @@ -140,44 +140,20 @@ 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'/, - ); + 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/); + } }); test('canConnectSocket times out stalled local daemon probes', async () => { diff --git a/src/daemon-client/daemon-client-lifecycle.ts b/src/daemon-client/daemon-client-lifecycle.ts index 7e75749e61..77d46502a5 100644 --- a/src/daemon-client/daemon-client-lifecycle.ts +++ b/src/daemon-client/daemon-client-lifecycle.ts @@ -8,10 +8,11 @@ import { shellQuoteIfNeeded } from '@agent-device/kernel/device-shell'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { isProcessAlive } from '@agent-device/host-kit/process'; import { sleep } from '@agent-device/host-kit/retry'; -import { inspectProcessLock } from '@agent-device/host-kit/file'; +import { inspectProcessLock, type ProcessLockInspection } from '@agent-device/host-kit/file'; import type { findUnrecoveredRepairCommitFailure } from '../session-repair-tombstone.ts'; import { + DAEMON_STARTUP_EXIT_CODES, createOwnedReplayStateDir, recoverAbandonedDaemonRegistration, type DaemonRetirementResult, @@ -36,14 +37,10 @@ import { import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; import { - cleanupStaleDaemonLockIfSafe, getDaemonMetadataState, - isDaemonLockHeldByAnotherDaemon, isRemoteDaemon, readDaemonInfo, - removeDaemonInfo, resolveDaemonStartupHint, - stopDaemonProcessForTakeover, type DaemonInfo, } from './daemon-client-metadata.ts'; import { @@ -69,7 +66,7 @@ export type EnsuredDaemon = { type DaemonStartupWaitResult = | { kind: 'ready'; daemon: EnsuredDaemon } | { kind: 'early_exit'; exit: ExecDetachedExit } - | { kind: 'timeout' }; + | { kind: 'retry' | 'unproven' | 'timeout' }; const DAEMON_STARTUP_TIMEOUT_MS = 15_000; const LIVE_DAEMON_PROBE_RETRIES = 3; @@ -166,7 +163,6 @@ async function ensureLocalDaemon(settings: DaemonClientSettings): Promise { +async function readReusableLocalDaemon( + settings: DaemonClientSettings, + deadline?: number, +): Promise { + const inspection = inspectProcessLock(settings.paths.lockPath); + if (inspection.state === 'unproven') { + throw 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), + }); + } const existing = readDaemonInfo(settings.paths.infoPath); if (!existing) 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 (deadline !== undefined && Date.now() >= deadline) return null; emitDaemonTakeoverNotice(existing, decision.reason, settings.paths.baseDir); - await stopDaemonProcessForTakeover(existing); - removeDaemonInfo(settings.paths.infoPath); + await retireDaemonForTakeover(existing, settings.paths); return null; } +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 @@ -220,12 +245,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', @@ -261,9 +288,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; @@ -298,7 +326,7 @@ function emitDaemonTakeoverNotice(info: DaemonInfo, reason: string, stateDir: st } type FailedDaemonStartup = { - cleanup: DaemonRetirementResult; + cleanup?: DaemonRetirementResult; startError?: string; daemonProcess?: ExecDetachedExit | { pid: number }; retry: boolean; @@ -314,7 +342,7 @@ async function startLocalDaemon(settings: DaemonClientSettings): Promise { 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 inspection = inspectProcessLock(settings.paths.lockPath); - const available = + 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')); - return { - cleanup, - retry: startup.kind === 'early_exit' && available, - startError: startup.kind === 'early_exit' ? describeDaemonEarlyExit(startup.exit) : undefined, - daemonProcess: startup.kind === 'early_exit' ? startup.exit : { pid: launch.pid }, - }; + inspection.liveness === 'owner-process-reused')) + ); } /** @@ -383,7 +455,7 @@ async function attemptLocalDaemonStartup( * (`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 @@ -629,36 +701,93 @@ 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' }; + 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 { + const winner = await readReusableLocalDaemon(settings, deadline); + 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' }; + 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 ( diff --git a/src/daemon-client/daemon-client-metadata.ts b/src/daemon-client/daemon-client-metadata.ts index bf886cef5c..1be88db68b 100644 --- a/src/daemon-client/daemon-client-metadata.ts +++ b/src/daemon-client/daemon-client-metadata.ts @@ -1,11 +1,7 @@ import fs from 'node:fs'; +import { inspectProcessLock, type ProcessLockInspection } from '@agent-device/host-kit/file'; import { AppError } from '@agent-device/kernel/errors'; -import { shellQuote } from '@agent-device/kernel/device-shell'; -import { - isAgentDeviceDaemonProcess, - stopDaemonProcess, - type DaemonTerminationResult, -} from '../daemon-process.ts'; +import { stopDaemonProcess, type DaemonTerminationResult } from '../daemon-process.ts'; import type { DaemonCodeOrigin } from '@agent-device/host-kit/code-signature'; @@ -32,15 +28,10 @@ export type DaemonInfo = { remoteUpstreamInstanceId?: string; }; -type DaemonLockInfo = { - pid: number; - processStartTime?: string; - startedAt?: number; -}; - export type DaemonMetadataState = { hasInfo: boolean; hasLock: boolean; + registration: ProcessLockInspection; }; const DAEMON_TAKEOVER_TERM_TIMEOUT_MS = 3000; @@ -96,35 +87,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); } @@ -133,24 +95,11 @@ 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 function getDaemonMetadataState(paths: DaemonPaths): DaemonMetadataState { return { hasInfo: fs.existsSync(paths.infoPath), hasLock: fs.existsSync(paths.lockPath), + registration: inspectProcessLock(paths.lockPath), }; } @@ -187,21 +136,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 { diff --git a/src/daemon-client/daemon-client-transport.ts b/src/daemon-client/daemon-client-transport.ts index d09d5af386..aa0f93820d 100644 --- a/src/daemon-client/daemon-client-transport.ts +++ b/src/daemon-client/daemon-client-transport.ts @@ -72,23 +72,31 @@ 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; + if (await canConnectWithTransport(info, transport, deadline - Date.now())) return true; const fallback = chooseAutoFallbackTransport(info, preference, transport); - return fallback ? await canConnectWithTransport(info, fallback) : false; + return fallback ? await canConnectWithTransport(info, fallback, deadline - Date.now()) : 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 +108,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 +118,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( diff --git a/website/docs/docs/installation.md b/website/docs/docs/installation.md index eca4ff398d..02886095b4 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 their newest mtime. It retires only confirmed abandoned registration and retains shared directories, session artifacts and logs. From 604939b7d66cbb3c3447efa73ad3a8160aced591 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 03:19:37 +0200 Subject: [PATCH 08/44] fix: observe only the current daemon registration owner --- .../__tests__/daemon-client-lifecycle.test.ts | 1 + .../daemon-client-startup-race.test.ts | 38 ++++++++--- src/daemon-client/daemon-client-lifecycle.ts | 66 ++++++++++++++----- src/daemon-client/daemon-client-metadata.ts | 3 - website/docs/docs/installation.md | 2 +- 5 files changed, 81 insertions(+), 29 deletions(-) diff --git a/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts b/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts index 281965b3e9..88d57bbc14 100644 --- a/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts +++ b/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts @@ -446,6 +446,7 @@ test('daemon acquisition reclaims a proven reused owner before publication', asy 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); await finishRegisteredDaemonFixture(stateDir); } 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 303a8b1faa..d5a2b7ede4 100644 --- a/src/daemon-client/__tests__/daemon-client-startup-race.test.ts +++ b/src/daemon-client/__tests__/daemon-client-startup-race.test.ts @@ -76,6 +76,10 @@ for (const command of ['devices', 'test']) { 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; @@ -250,7 +254,10 @@ for (const held of [true, false]) { }); } -test('a joined busy contender retires an older winner before relaunching', async (t) => { +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: [] }); @@ -260,6 +267,9 @@ test('a joined busy contender retires an older winner before relaunching', async 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); @@ -272,20 +282,30 @@ test('a joined busy contender retires an older winner before relaunching', async return child; }); pause.mockImplementation(async (ms) => { - if (joined) fs.rmSync(deferred, { force: true }); - await actualRetry.sleep(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 { - assert.equal((await sendToDaemon(request(paths))).ok, true); - await winner.exited; - assert.equal(isProcessAlive(winner.pid), false); - assert.equal(spawn.mock.calls.length, 2); - assert.equal(http.rpcRequests.length, 1); - assert.ok( + const pending = sendToDaemon(request(paths)); + if (expected.alive) await assert.rejects(pending); + 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 { + clock.mockRestore(); notice.mockRestore(); await closeLoopbackServer(http.server); } diff --git a/src/daemon-client/daemon-client-lifecycle.ts b/src/daemon-client/daemon-client-lifecycle.ts index 77d46502a5..7b25dd630c 100644 --- a/src/daemon-client/daemon-client-lifecycle.ts +++ b/src/daemon-client/daemon-client-lifecycle.ts @@ -66,9 +66,11 @@ export type EnsuredDaemon = { type DaemonStartupWaitResult = | { kind: 'ready'; daemon: EnsuredDaemon } | { kind: 'early_exit'; exit: ExecDetachedExit } - | { kind: 'retry' | 'unproven' | '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; @@ -191,16 +193,10 @@ async function readReusableLocalDaemon( deadline?: number, ): Promise { const inspection = inspectProcessLock(settings.paths.lockPath); - if (inspection.state === 'unproven') { - throw 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), - }); - } + if (inspection.state === 'unproven') throw daemonRegistrationUnprovenError(settings, inspection); const existing = readDaemonInfo(settings.paths.infoPath); if (!existing) return null; + if (!registrationAllowsDaemonObservation(inspection, existing)) return null; const decision = await resolveDaemonTakeover(existing, { onClientTransport: () => @@ -212,12 +208,36 @@ async function readReusableLocalDaemon( throw newerDaemonRefusedError(existing, decision, settings.paths.baseDir); } - if (deadline !== undefined && Date.now() >= deadline) return null; + if (remainingStartupBudget(deadline) < MINIMUM_DAEMON_TAKEOVER_BUDGET_MS) return null; emitDaemonTakeoverNotice(existing, decision.reason, settings.paths.baseDir); 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, @@ -328,6 +348,7 @@ function emitDaemonTakeoverNotice(info: DaemonInfo, reason: string, stateDir: st type FailedDaemonStartup = { cleanup?: DaemonRetirementResult; startError?: string; + startupError?: NormalizedError; daemonProcess?: ExecDetachedExit | { pid: number }; retry: boolean; }; @@ -346,6 +367,7 @@ async function startLocalDaemon(settings: DaemonClientSettings): Promise = failure ?? {}; const state = getDaemonMetadataState(settings.paths); const daemonLogTail = readRecentLogTail(settings.paths.logPath); throw new AppError('COMMAND_FAILED', 'Failed to start daemon', { @@ -357,8 +379,9 @@ async function startLocalDaemon(settings: DaemonClientSettings): Promise { - const winner = await readReusableLocalDaemon(settings, deadline); + let winner: DaemonInfo | null; + try { + winner = await readReusableLocalDaemon(settings, deadline); + } 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' }; + if (inspection.state === 'unproven') + return { kind: 'unproven', error: daemonRegistrationUnprovenError(settings, inspection) }; return isRegistrationAvailable(inspection) ? { kind: 'retry' } : null; } diff --git a/src/daemon-client/daemon-client-metadata.ts b/src/daemon-client/daemon-client-metadata.ts index 1be88db68b..5e9d060daa 100644 --- a/src/daemon-client/daemon-client-metadata.ts +++ b/src/daemon-client/daemon-client-metadata.ts @@ -1,5 +1,4 @@ import fs from 'node:fs'; -import { inspectProcessLock, type ProcessLockInspection } from '@agent-device/host-kit/file'; import { AppError } from '@agent-device/kernel/errors'; import { stopDaemonProcess, type DaemonTerminationResult } from '../daemon-process.ts'; @@ -31,7 +30,6 @@ export type DaemonInfo = { export type DaemonMetadataState = { hasInfo: boolean; hasLock: boolean; - registration: ProcessLockInspection; }; const DAEMON_TAKEOVER_TERM_TIMEOUT_MS = 3000; @@ -99,7 +97,6 @@ export function getDaemonMetadataState(paths: DaemonPaths): DaemonMetadataState return { hasInfo: fs.existsSync(paths.infoPath), hasLock: fs.existsSync(paths.lockPath), - registration: inspectProcessLock(paths.lockPath), }; } diff --git a/website/docs/docs/installation.md b/website/docs/docs/installation.md index 02886095b4..cef44e2760 100644 --- a/website/docs/docs/installation.md +++ b/website/docs/docs/installation.md @@ -126,4 +126,4 @@ Startup refuses legacy lock files and unverified ownership. Confirm every user o 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 their newest mtime. It retires only confirmed abandoned registration and retains shared directories, session artifacts and logs. +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. From d2c7a19c0644bbab5e8251c0d2a2120448b10e7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 21:16:14 +0200 Subject: [PATCH 09/44] test: bound held-claim startup deadline simulation --- .../__tests__/daemon-client-startup-race.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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 d5a2b7ede4..7991d75c32 100644 --- a/src/daemon-client/__tests__/daemon-client-startup-race.test.ts +++ b/src/daemon-client/__tests__/daemon-client-startup-race.test.ts @@ -215,6 +215,7 @@ for (const held of [true, false]) { 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) => @@ -231,9 +232,12 @@ for (const held of [true, false]) { }), })); pause.mockImplementation(async (ms) => { - if (!held && !released) { - await claim.acquisition.release(); - released = true; + if (!advanced) { + if (!held) { + await claim.acquisition.release(); + released = true; + } + advanced = true; now += 14_750; } else now += ms; }); From 3f2199b86142fb4fcdd9242f5fb9a4a5542844b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 03:50:19 +0200 Subject: [PATCH 10/44] fix: await owned daemon retirement on request timeout --- .../__tests__/daemon-client-lifecycle.test.ts | 46 +++-- .../__tests__/daemon-client-metadata.test.ts | 60 +----- .../daemon-client-timeout-route.test.ts | 193 +++++++++++++++--- .../__tests__/daemon-client-transport.test.ts | 61 ++++++ src/daemon-client/daemon-client-metadata.ts | 44 ---- src/daemon-client/daemon-client-timeout.ts | 78 +++---- src/daemon-client/daemon-client-transport.ts | 128 +++++++----- 7 files changed, 359 insertions(+), 251 deletions(-) diff --git a/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts b/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts index 88d57bbc14..487df065d2 100644 --- a/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts +++ b/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts @@ -27,6 +27,8 @@ 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 { readDaemonInfo } from '../daemon-client-metadata.ts'; +import type { DaemonRetirementResult } from '../../daemon-registration-owner.ts'; import { closeLoopbackServer, listenOnLoopback, @@ -615,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', @@ -638,26 +646,26 @@ test('sendRequest timeout cleanup uses resolved daemon paths instead of request }; try { + let info = readDaemonInfo(daemonPaths.infoPath); + for (let attempt = 0; !info && attempt < 200; attempt += 1) { + await actualRetry.sleep(10); + info = readDaemonInfo(daemonPaths.infoPath); + } + assert.ok(info); 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); @@ -665,7 +673,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 }); } }); diff --git a/src/daemon-client/__tests__/daemon-client-metadata.test.ts b/src/daemon-client/__tests__/daemon-client-metadata.test.ts index 6bfec46b6e..bae4084103 100644 --- a/src/daemon-client/__tests__/daemon-client-metadata.test.ts +++ b/src/daemon-client/__tests__/daemon-client-metadata.test.ts @@ -1,29 +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 { - stopAndRetireDaemon, - tryAcquireDaemonRegistration, -} from '../../daemon-registration-owner.ts'; -import { - readDaemonInfo, - stopDaemonProcessForTakeover, - type DaemonInfo, -} from '../daemon-client-metadata.ts'; -import { isAgentDeviceDaemonProcess, stopDaemonProcess } from '../../daemon-process.ts'; +import { tryAcquireDaemonRegistration } from '../../daemon-registration-owner.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). @@ -68,41 +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 stopAndRetireDaemon({ - paths, - observed: { pid: 7, startTime: 'start' }, - mode: 'graceful', - }); - assert.equal(fs.readFileSync(file, 'utf8'), contents); - assert.equal(result.removedInfo, false); - assert.equal(result.status, 'retained'); - if (result.status === 'retained') assert.equal(result.reason, 'exit-unconfirmed'); - }); -} - -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-timeout-route.test.ts b/src/daemon-client/__tests__/daemon-client-timeout-route.test.ts index f02582c40e..04d4154403 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,29 @@ 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 { 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 { readDaemonInfo, type DaemonInfo } from '../daemon-client-metadata.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, + 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 +97,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 +133,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 +284,156 @@ 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 () => { - 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' }); +async function publishedInfo(paths: DaemonPaths): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + const info = readDaemonInfo(paths.infoPath); + if (info) return info; + await sleep(10); + } + throw new Error('registered child did not publish'); +} + +for (const transport of ['socket', 'http'] as const) { + test(`${transport} timeout waits for force retirement before reporting reset`, 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); + }); + try { + const info = await publishedInfo(paths); + outcome = sendRequest( + info, + { ...buildRequest(undefined), command: 'open' }, + transport, + paths, + TIMEOUT_MS, + ).then( + () => assert.fail('hanging request unexpectedly succeeded'), + (error: unknown) => { + settled = true; + return error; + }, + ); + await Promise.race([ + requested, + sleep(1_500).then(() => assert.fail('force stop was not 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'); + const retirement = error.details?.retirement as DaemonRetirementResult | undefined; + assert.ok(retirement?.status === 'retired'); + assert.equal(retirement.termination.mode, 'forced'); + assert.equal(fs.existsSync(paths.infoPath), false); + assert.equal(fs.existsSync(paths.lockPath), false); + assert.equal(fs.existsSync(paths.baseDir), true); + assert.equal(mockRunCmdSync.mock.calls.filter(([cmd]) => cmd === 'pkill').length, 3); + } finally { + kill.mockRestore(); + if (!exited) actualKill(child.pid, 'SIGKILL'); + await child.exited; + await outcome; + await finishRegisteredDaemonFixture(paths.baseDir); + await closeLoopbackServer(endpoint.server); + } }); - const { server, port } = await startHangingSocketServer(); +} + +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: '' }); + 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 publishedInfo(paths); + 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' }, + { ...info, processStartTime: undefined }, { ...buildRequest(undefined), command: 'open' }, - 'socket', - dummyStatePaths(), + '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(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/daemon-client-metadata.ts b/src/daemon-client/daemon-client-metadata.ts index 5e9d060daa..d517da18e4 100644 --- a/src/daemon-client/daemon-client-metadata.ts +++ b/src/daemon-client/daemon-client-metadata.ts @@ -1,6 +1,4 @@ import fs from 'node:fs'; -import { AppError } from '@agent-device/kernel/errors'; -import { stopDaemonProcess, type DaemonTerminationResult } from '../daemon-process.ts'; import type { DaemonCodeOrigin } from '@agent-device/host-kit/code-signature'; @@ -32,9 +30,6 @@ export type DaemonMetadataState = { hasLock: boolean; }; -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; @@ -85,14 +80,6 @@ function readPositiveInteger(value: unknown): number | undefined { return Number.isInteger(value) && Number(value) > 0 ? Number(value) : undefined; } -export function removeDaemonInfo(infoPath: string): void { - removeFileIfExists(infoPath); -} - -export function removeDaemonLock(lockPath: string): void { - removeFileIfExists(lockPath); -} - export function getDaemonMetadataState(paths: DaemonPaths): DaemonMetadataState { return { hasInfo: fs.existsSync(paths.infoPath), @@ -100,29 +87,6 @@ export function getDaemonMetadataState(paths: DaemonPaths): DaemonMetadataState }; } -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; } @@ -147,11 +111,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..c27c95b1b7 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,19 @@ 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 forcedKill = + retirement?.status !== 'absent' && + retirement?.termination?.status === 'exited' && + retirement.termination.mode === 'forced'; // 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,8 +94,9 @@ export function handleRequestTimeout( command, timedOutRunnerPidsTerminated: cleanup.terminated, timedOutRunnerCleanupError: cleanup.error, - daemonPidReset: resetDaemon ? info.pid : undefined, - daemonPidForceKilled: resetDaemon ? daemonReset.forcedKill : undefined, + daemonPidReset: retirement?.status === 'retired' ? info.pid : undefined, + daemonPidForceKilled: resetDaemon ? forcedKill : undefined, + daemonRetirement: retirement, daemonPreservedAfterTimeout: !remote && !resetDaemon, daemonBaseUrl: info.baseUrl, }, @@ -99,14 +105,18 @@ 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, + }), }); } @@ -177,25 +187,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 aa0f93820d..66d545b0a3 100644 --- a/src/daemon-client/daemon-client-transport.ts +++ b/src/daemon-client/daemon-client-transport.ts @@ -76,10 +76,13 @@ export async function canConnect( ): Promise { const deadline = Date.now() + (probeTimeoutMs ?? Number.POSITIVE_INFINITY); const transport = chooseTransport(info, preference); - if (await canConnectWithTransport(info, transport, deadline - Date.now())) return true; - const fallback = chooseAutoFallbackTransport(info, preference, transport); - return fallback ? await canConnectWithTransport(info, fallback, deadline - Date.now()) : 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( @@ -160,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, @@ -190,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())); @@ -211,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 }; @@ -245,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; @@ -279,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, @@ -320,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 { @@ -359,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( @@ -454,7 +478,6 @@ function handleTransportError( async function sendSocketRequest( info: DaemonInfo, req: DaemonRequest, - statePaths: DaemonPaths, timeoutMs: number | undefined, options: SendRequestOptions, ): Promise { @@ -470,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; @@ -512,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'>; @@ -644,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; From 50eafe5c434ff946f70a50b04840058679d0afbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 03:56:11 +0200 Subject: [PATCH 11/44] chore(gates): acknowledge unchanged health payload under probe deadlines --- test/wire-compat/ledger.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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." } ] } From 740111b0f59761505e30cdc01d6f1c1a8df9de14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 04:24:26 +0200 Subject: [PATCH 12/44] fix: share manual daemon retirement and verify lock cutover --- docs/adr/0030-process-lock-exclusion.md | 32 +++- .../daemon-registration-owner.test.ts | 159 +++++++++++++++++- .../test-utils/legacy-daemon-fixture.ts | 92 ++++++++++ .../test-utils/registered-daemon-fixture.ts | 10 +- src/daemon/__tests__/daemon-stop.test.ts | 129 ++++++++------ src/daemon/daemon-stop.ts | 56 +++--- 6 files changed, 400 insertions(+), 78 deletions(-) create mode 100644 src/__tests__/test-utils/legacy-daemon-fixture.ts 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/src/__tests__/daemon-registration-owner.test.ts b/src/__tests__/daemon-registration-owner.test.ts index 7f4795f6d8..47710d6a62 100644 --- a/src/__tests__/daemon-registration-owner.test.ts +++ b/src/__tests__/daemon-registration-owner.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; +import path from 'node:path'; import { afterEach, test, vi } from 'vitest'; import { readCurrentOwnerIdentity, isProcessAlive } from '@agent-device/host-kit/process'; import { @@ -15,9 +16,18 @@ 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 } from './test-utils/registered-daemon-fixture.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, @@ -465,3 +475,150 @@ async function finishPrivateTestDaemons( } 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 legacy.exited; + assert.equal(legacyDisposition(paths), false); + 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/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.ts b/src/__tests__/test-utils/registered-daemon-fixture.ts index 12af382c10..ea80ae08b6 100644 --- a/src/__tests__/test-utils/registered-daemon-fixture.ts +++ b/src/__tests__/test-utils/registered-daemon-fixture.ts @@ -20,6 +20,7 @@ const children = new Map< 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 }); @@ -31,9 +32,15 @@ export function registeredDaemonFixtureArgs( 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'); @@ -56,10 +63,11 @@ export function spawnRegisteredDaemonFixture( paths: DaemonPaths, fields: DaemonRegistrationFields, options: Parameters[2], + acquisitionBarrier?: string, ): ReturnType { const child = actualCommand.runCmdDetachedMonitored( process.execPath, - registeredDaemonFixtureArgs(paths, fields), + registeredDaemonFixtureArgs(paths, fields, acquisitionBarrier), options, ); const owned = children.get(paths.baseDir) ?? []; 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/daemon-stop.ts b/src/daemon/daemon-stop.ts index bf5be1697b..0f1ae4e1d0 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 { stopAndRetireDaemon, 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,18 @@ export async function stopDaemon(params: { }): Promise { const info = readRegisteredDaemonIdentity(params.paths.infoPath); if (!info) return notRunningResult(); - const termination = await stopDaemonProcess(info, { + 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 +83,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 +112,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, From 12781ae98f49f01d909e6ea0e74d678d9744a80d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 04:27:25 +0200 Subject: [PATCH 13/44] test: bound legacy contender admission observation --- src/__tests__/daemon-registration-owner.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/__tests__/daemon-registration-owner.test.ts b/src/__tests__/daemon-registration-owner.test.ts index 47710d6a62..5426a07764 100644 --- a/src/__tests__/daemon-registration-owner.test.ts +++ b/src/__tests__/daemon-registration-owner.test.ts @@ -542,8 +542,11 @@ test('a legacy contender cannot unlink a hardened owner while it delays metadata try { await waitForCutoverFixture(() => fs.existsSync(path.join(paths.baseDir, 'registration-held'))); legacy = spawnLegacyDaemonFixture(paths); - await legacy.exited; + 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'); From 464176df9497e2edd515d7b0a14c485b47b8502a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 05:08:22 +0200 Subject: [PATCH 14/44] fix: preserve daemon ownership across startup and cleanup --- .../internal/owner-identity-liveness.test.ts | 8 ++ .../host-kit/src/internal/owner-identity.ts | 11 ++- .../host-kit/src/internal/process-lock.ts | 3 +- src/__tests__/daemon-exit-wait.test.ts | 10 +++ src/__tests__/daemon-process-takeover.test.ts | 13 ++++ .../test-utils/daemon-http-fixture.ts | 3 +- .../test-utils/registered-daemon-fixture.ts | 21 ++++- .../__tests__/daemon-client-lifecycle.test.ts | 9 +-- .../daemon-client-startup-race.test.ts | 49 +++++++++++- .../daemon-client-timeout-route.test.ts | 75 ++++++++++++------ src/daemon-client/daemon-client-lifecycle.ts | 11 ++- src/daemon-client/daemon-client-metadata.ts | 3 +- src/daemon-client/daemon-client-timeout.ts | 15 ++-- src/daemon-process.ts | 3 + src/daemon-registration.ts | 3 +- src/daemon/daemon-stop.ts | 3 +- .../__tests__/session-device-claims.test.ts | 3 - .../support/daemon-test-cleanup.test.ts | 77 +++++++++++++++++++ .../support/daemon-test-cleanup.ts | 47 ++++++----- 19 files changed, 298 insertions(+), 69 deletions(-) create mode 100644 test/integration/support/daemon-test-cleanup.test.ts 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..772d29cac5 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,11 @@ 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); +}); 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/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__/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/registered-daemon-fixture.ts b/src/__tests__/test-utils/registered-daemon-fixture.ts index ea80ae08b6..6c99455a4e 100644 --- a/src/__tests__/test-utils/registered-daemon-fixture.ts +++ b/src/__tests__/test-utils/registered-daemon-fixture.ts @@ -2,7 +2,8 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { vi } from 'vitest'; -import type { runCmdDetachedMonitored } from '@agent-device/host-kit/command'; +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'; @@ -76,6 +77,24 @@ export function spawnRegisteredDaemonFixture( return child; } +export async function waitForRegisteredDaemonFixture( + paths: DaemonPaths, + child: ReturnType, +): Promise { + let exit: ExecDetachedExit | undefined; + void child.exited.then((result) => { + exit = result; + }); + 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; diff --git a/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts b/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts index 487df065d2..9c67e88a31 100644 --- a/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts +++ b/src/daemon-client/__tests__/daemon-client-lifecycle.test.ts @@ -8,6 +8,7 @@ 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'; @@ -27,7 +28,6 @@ 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 { readDaemonInfo } from '../daemon-client-metadata.ts'; import type { DaemonRetirementResult } from '../../daemon-registration-owner.ts'; import { closeLoopbackServer, @@ -646,12 +646,7 @@ test('sendRequest timeout cleanup uses resolved daemon paths instead of request }; try { - let info = readDaemonInfo(daemonPaths.infoPath); - for (let attempt = 0; !info && attempt < 200; attempt += 1) { - await actualRetry.sleep(10); - info = readDaemonInfo(daemonPaths.infoPath); - } - assert.ok(info); + const info = await waitForRegisteredDaemonFixture(daemonPaths, child); let thrown: unknown; try { await sendRequest(info, request, 'http', daemonPaths, 50); 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 7991d75c32..8788a8c887 100644 --- a/src/daemon-client/__tests__/daemon-client-startup-race.test.ts +++ b/src/daemon-client/__tests__/daemon-client-startup-race.test.ts @@ -203,6 +203,46 @@ for (const exit of [ }); } +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 joined = false; + spawn.mockImplementation((_command, _args, options) => { + const child = spawnRegisteredDaemonFixture(paths, fields(http.port), options); + void child.exited.then((exit) => { + assert.equal(exit.exitCode, DAEMON_STARTUP_EXIT_CODES.busy); + joined = true; + }); + return child; + }); + pause.mockImplementation(async () => { + if (joined) fs.rmSync(deferred, { force: true }); + await actualRetry.sleep(10); + }); + const signal = vi.spyOn(process, 'kill'); + try { + assert.equal((await sendToDaemon(request(paths))).ok, true); + assert.equal(joined, true); + 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-')); @@ -295,7 +335,14 @@ test.for([ const notice = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); try { const pending = sendToDaemon(request(paths)); - if (expected.alive) await assert.rejects(pending); + 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; 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 04d4154403..c28d397d1d 100644 --- a/src/daemon-client/__tests__/daemon-client-timeout-route.test.ts +++ b/src/daemon-client/__tests__/daemon-client-timeout-route.test.ts @@ -41,14 +41,16 @@ vi.mock('@agent-device/host-kit/command', async () => { 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 { readDaemonInfo, type DaemonInfo } from '../daemon-client-metadata.ts'; +import type { DaemonInfo } from '../daemon-client-metadata.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'; @@ -284,13 +286,29 @@ test('remote HTTP timeout never runs the Apple pkill cleanup and uses the remote assert.equal(mockRunCmdSync.mock.calls.length, 0); }); -async function publishedInfo(paths: DaemonPaths): Promise { - for (let attempt = 0; attempt < 200; attempt += 1) { - const info = readDaemonInfo(paths.infoPath); - if (info) return info; - await sleep(10); +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); } - throw new Error('registered child did not publish'); +} + +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; } for (const transport of ['socket', 'http'] as const) { @@ -331,13 +349,17 @@ for (const transport of ['socket', 'http'] as const) { return actualKill(pid, signal); }); try { - const info = await publishedInfo(paths); - outcome = sendRequest( - info, - { ...buildRequest(undefined), command: 'open' }, - transport, - paths, - TIMEOUT_MS, + 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) => { @@ -345,10 +367,7 @@ for (const transport of ['socket', 'http'] as const) { return error; }, ); - await Promise.race([ - requested, - sleep(1_500).then(() => assert.fail('force stop was not requested')), - ]); + 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'); @@ -364,6 +383,7 @@ for (const transport of ['socket', 'http'] as const) { assert.equal(fs.existsSync(paths.infoPath), false); assert.equal(fs.existsSync(paths.lockPath), false); assert.equal(fs.existsSync(paths.baseDir), true); + assert.equal(timeoutDiagnostic(paths).daemonPreservedAfterTimeout, false); assert.equal(mockRunCmdSync.mock.calls.filter(([cmd]) => cmd === 'pkill').length, 3); } finally { kill.mockRestore(); @@ -394,18 +414,22 @@ test('timeout retains a live registration without captured birth proof and repor ); const kill = vi.spyOn(process, 'kill'); try { - const info = await publishedInfo(paths); + 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( - { ...info, processStartTime: undefined }, - { ...buildRequest(undefined), command: 'open' }, - 'http', - paths, - 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); @@ -419,6 +443,7 @@ test('timeout retains a live registration without captured birth proof and repor return true; }, ); + assert.equal(timeoutDiagnostic(paths).daemonPreservedAfterTimeout, true); assert.equal(process.kill(child.pid, 0), true); assert.equal(fs.readFileSync(paths.infoPath, 'utf8'), before); assert.deepEqual( diff --git a/src/daemon-client/daemon-client-lifecycle.ts b/src/daemon-client/daemon-client-lifecycle.ts index 7b25dd630c..1d76d43ecf 100644 --- a/src/daemon-client/daemon-client-lifecycle.ts +++ b/src/daemon-client/daemon-client-lifecycle.ts @@ -190,13 +190,20 @@ async function ensureRemoteDaemon(settings: DaemonClientSettings): 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: () => @@ -769,7 +776,7 @@ async function observeContendingDaemon( ): Promise { let winner: DaemonInfo | null; try { - winner = await readReusableLocalDaemon(settings, deadline); + winner = await readReusableLocalDaemon(settings, { deadline, waitForLiveStartup: true }); } catch (error) { if (error instanceof AppError && error.details?.reason === 'daemon_registration_unproven') return { kind: 'unproven', error }; diff --git a/src/daemon-client/daemon-client-metadata.ts b/src/daemon-client/daemon-client-metadata.ts index d517da18e4..718ed7b1cc 100644 --- a/src/daemon-client/daemon-client-metadata.ts +++ b/src/daemon-client/daemon-client-metadata.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import { isProcessPid } from '@agent-device/host-kit/process'; import type { DaemonCodeOrigin } from '@agent-device/host-kit/code-signature'; @@ -42,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), diff --git a/src/daemon-client/daemon-client-timeout.ts b/src/daemon-client/daemon-client-timeout.ts index c27c95b1b7..f7132c4f87 100644 --- a/src/daemon-client/daemon-client-timeout.ts +++ b/src/daemon-client/daemon-client-timeout.ts @@ -73,10 +73,7 @@ export async function handleRequestTimeout( mode: 'force', }); } - const forcedKill = - retirement?.status !== 'absent' && - retirement?.termination?.status === 'exited' && - retirement.termination.mode === 'forced'; + const retained = retirement?.status === 'retained'; // 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 @@ -95,9 +92,9 @@ export async function handleRequestTimeout( timedOutRunnerPidsTerminated: cleanup.terminated, timedOutRunnerCleanupError: cleanup.error, daemonPidReset: retirement?.status === 'retired' ? info.pid : undefined, - daemonPidForceKilled: resetDaemon ? forcedKill : undefined, + daemonPidForceKilled: resetDaemon ? daemonWasForceKilled(retirement) : undefined, daemonRetirement: retirement, - daemonPreservedAfterTimeout: !remote && !resetDaemon, + daemonPreservedAfterTimeout: retained || (!remote && !resetDaemon), daemonBaseUrl: info.baseUrl, }, }); @@ -120,6 +117,12 @@ export async function handleRequestTimeout( }); } +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 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.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/daemon-stop.ts b/src/daemon/daemon-stop.ts index 0f1ae4e1d0..c5e53eefcf 100644 --- a/src/daemon/daemon-stop.ts +++ b/src/daemon/daemon-stop.ts @@ -1,5 +1,5 @@ import { AppError } from '@agent-device/kernel/errors'; -import { stopAndRetireDaemon, type DaemonRetirementResult } from '../daemon-registration-owner.ts'; +import type { DaemonRetirementResult } from '../daemon-registration-owner.ts'; import type { OwnerIdentity } from '@agent-device/host-kit/process'; import type { DaemonPaths } from '../daemon-resolution.ts'; @@ -43,6 +43,7 @@ export async function stopDaemon(params: { }): Promise { const info = readRegisteredDaemonIdentity(params.paths.infoPath); if (!info) return notRunningResult(); + const { stopAndRetireDaemon } = await import('../daemon-registration-owner.ts'); const retirement = await stopAndRetireDaemon({ paths: params.paths, observed: info, 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/test/integration/support/daemon-test-cleanup.test.ts b/test/integration/support/daemon-test-cleanup.test.ts new file mode 100644 index 0000000000..a83780c9d5 --- /dev/null +++ b/test/integration/support/daemon-test-cleanup.test.ts @@ -0,0 +1,77 @@ +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('cleanup stops the registered replacement when its observation still names the exited original', async () => { + 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); + 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); + } +}); diff --git a/test/integration/support/daemon-test-cleanup.ts b/test/integration/support/daemon-test-cleanup.ts index 371159c521..848a90d450 100644 --- a/test/integration/support/daemon-test-cleanup.ts +++ b/test/integration/support/daemon-test-cleanup.ts @@ -2,6 +2,11 @@ import fs from 'node:fs'; import path from 'node:path'; import { normalizeError } from '@agent-device/kernel/errors'; import { stopDaemonProcess } from '../../../src/daemon-process.ts'; +import { + readRegisteredDaemonIdentity, + readRegisteredDaemonOwnership, +} from '../../../src/daemon-registration.ts'; +import { ownerIdentityMatches, type OwnerIdentity } from '@agent-device/host-kit/process'; type TestDaemonIdentity = { pid: number; processStartTime?: string }; @@ -11,29 +16,35 @@ 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 identities = [ + observed ? { pid: observed.pid, startTime: observed.processStartTime ?? null } : null, + readIdentity(stateDir), + ].filter((identity): identity is OwnerIdentity => identity !== null); + if (identities.length === 0) throw new Error('No daemon lifetime was observed'); + for (const identity of identities) { + const termination = await stopDaemonProcess(identity, { + mode: 'graceful', + termTimeoutMs: 1_500, + killTimeoutMs: 1_500, + }); + if (termination.status !== 'exited') { + console.warn('Daemon test cleanup retained state:', stateDir, termination); + return; + } } + const current = readIdentity(stateDir); + if (current && !identities.some((identity) => ownerIdentityMatches(identity, current))) + throw new Error('Daemon registration changed during test cleanup'); 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(stateDir: string): OwnerIdentity | null { + const infoPath = path.join(stateDir, 'daemon.json'); + 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; } From e4a21ee8b4c3294e00989a373197d74aab556236 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 05:08:22 +0200 Subject: [PATCH 15/44] chore(gates): expose native PID validation and discover cleanup controls --- packages/host-kit/src/process.ts | 1 + vitest.config.ts | 1 + 2 files changed, 2 insertions(+) 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/vitest.config.ts b/vitest.config.ts index 1b4390fc19..bb9a0a0046 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -178,6 +178,7 @@ 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', + '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', From 4ef85e9d075e2198a09c82269bb3459152064b73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 05:31:36 +0200 Subject: [PATCH 16/44] refactor: bind session references to stable lifetime entries --- src/__tests__/test-utils/store-factory.ts | 5 + .../request-lock-identity-policy.test.ts | 3 +- .../__tests__/request-lock-policy.test.ts | 3 +- src/daemon/__tests__/session-selector.test.ts | 3 +- .../__tests__/session-store-lifetime.test.ts | 118 ++++++++++++++++++ src/daemon/server/daemon-runtime.ts | 1 + .../session-open-device-in-use.test.ts | 17 +-- .../session-close-lifecycle-teardown.ts | 10 +- .../internal/session-close.ts | 20 ++- src/daemon/session-recovery-hints.test.ts | 19 ++- src/daemon/session-state.ts | 5 +- src/daemon/session-store.ts | 96 +++++++++++--- 12 files changed, 243 insertions(+), 57 deletions(-) create mode 100644 src/daemon/__tests__/session-store-lifetime.test.ts diff --git a/src/__tests__/test-utils/store-factory.ts b/src/__tests__/test-utils/store-factory.ts index 6fccc173af..0c9d222497 100644 --- a/src/__tests__/test-utils/store-factory.ts +++ b/src/__tests__/test-utils/store-factory.ts @@ -1,8 +1,13 @@ import path from 'node:path'; import { SessionStore } from '../../daemon/session-store.ts'; import { mkdtempForTestSync } from './tmp-dir.ts'; +import type { SessionRef, SessionState } from '../../daemon/session-state.ts'; export function makeSessionStore(prefix = 'agent-device-test-'): SessionStore { const tempRoot = mkdtempForTestSync(prefix); return new SessionStore(path.join(tempRoot, 'sessions')); } + +export function makeStoredSessionRef(session: SessionState, address = session.name): SessionRef { + return makeSessionStore().publish(address, session); +} diff --git a/src/daemon/__tests__/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__/session-selector.test.ts b/src/daemon/__tests__/session-selector.test.ts index a224a7a059..54e978d994 100644 --- a/src/daemon/__tests__/session-selector.test.ts +++ b/src/daemon/__tests__/session-selector.test.ts @@ -1,3 +1,4 @@ +import { makeStoredSessionRef } from '../../__tests__/test-utils/store-factory.ts'; import { test } from 'vitest'; import assert from 'node:assert/strict'; import { assertSessionSelectorMatches } from '../session-selector.ts'; @@ -24,7 +25,7 @@ function makeSession(overrides?: Partial): SessionState { /** These sessions are explicitly named, so each is stored under — and addressed by — its name. */ function ref(session: SessionState): SessionRef { - return { address: session.name, session }; + return makeStoredSessionRef(session); } test('accepts matching platform and serial selectors', () => { diff --git a/src/daemon/__tests__/session-store-lifetime.test.ts b/src/daemon/__tests__/session-store-lifetime.test.ts new file mode 100644 index 0000000000..d3b7ae7e65 --- /dev/null +++ b/src/daemon/__tests__/session-store-lifetime.test.ts @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; +import { makeSession } from '../../__tests__/test-utils/session-factories.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; + +const ADDRESS = 'cwd:worktree:default'; + +function ended(error: unknown): boolean { + return error instanceof AppError && error.details?.reason === 'session_lifetime_ended'; +} + +test('refs capture records while resolving rebuilds from the same lifetime', () => { + const store = makeSessionStore(); + const session = makeSession('default'); + const initial = store.publish(ADDRESS, session); + const lookup = store.lookup(ADDRESS)!; + const listed = store.listRefs()[0]!; + const byDevice = store.findByDevice(session.device.id)!; + for (const ref of [lookup, listed, byDevice]) { + assert.notEqual(ref, initial); + assert.equal(ref.lifetime, initial.lifetime); + assert.equal(ref.session, session); + assert.equal(Object.isFrozen(ref), true); + } + const rebuilt = store.update(initial, { appName: 'Reopened' }); + assert.equal(initial.session, session); + assert.equal(initial.session.appName, undefined); + for (const ref of [initial, lookup, listed, byDevice]) { + assert.equal(store.resolveCurrent(ref), rebuilt); + } + assert.equal(store.lookup(ADDRESS)?.session, rebuilt); + 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.throws(() => store.requireCurrent(old), ended); + assert.throws(() => store.update(old, { appName: 'Stale' }), ended); + assert.equal(store.retire(old), false); + assert.equal(store.get(ADDRESS), session); + assert.equal(store.getRuntimeHints(ADDRESS)?.metroPort, 8082); + assert.equal(store.retire(successor), true); + assert.equal(store.getRuntimeHints(ADDRESS), undefined); +}); + +test('retired updates cannot run their derivation or resurrect a record', () => { + const store = makeSessionStore(); + const ref = store.publish(ADDRESS, makeSession('default')); + store.retire(ref); + let ran = false; + assert.throws( + () => + store.update(ref, () => { + ran = true; + return { appName: 'Late' }; + }), + ended, + ); + assert.equal(ran, false); + assert.equal(store.lookup(ADDRESS), undefined); +}); + +test('an occupied address cannot be published again', () => { + const store = makeSessionStore(); + const ref = store.publish(ADDRESS, makeSession('default')); + assert.throws( + () => store.publish(ADDRESS, makeSession('default')), + (error) => error instanceof AppError && error.details?.reason === 'session_address_occupied', + ); + assert.equal(store.requireCurrent(ref), ref.session); +}); + +test('shutdown closes draft admission while allowing the current lifetime to settle', () => { + const store = makeSessionStore(); + const ref = store.publish(ADDRESS, makeSession('default')); + store.closeAdmission(); + assert.throws( + () => store.publish('late-draft', makeSession('late-draft')), + (error) => error instanceof AppError && error.details?.reason === 'daemon_shutting_down', + ); + store.update(ref, { appName: 'Settled' }); + assert.equal(store.requireCurrent(ref).appName, 'Settled'); + assert.equal(store.retire(ref), true); + assert.equal(store.lookup('late-draft'), undefined); +}); + +test('a ref from another store has no authority over the same address', () => { + const source = makeSessionStore(); + const target = makeSessionStore(); + const foreign = source.publish(ADDRESS, makeSession('default')); + const local = target.publish(ADDRESS, foreign.session); + assert.equal(target.resolveCurrent(foreign), undefined); + assert.throws(() => target.update(foreign, { appName: 'Foreign' }), ended); + assert.equal(target.retire(foreign), false); + assert.equal(target.requireCurrent(local), foreign.session); +}); diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index e561d56290..fe73e03097 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -754,6 +754,7 @@ export async function startDaemonRuntime( sessionIdleExpiry.cancel(); if (shuttingDown) return; shuttingDown = true; + sessionStore.closeAdmission(); stopMetadataLossWatch(); if (shutdownOptions.cause) { await emitFatalDiagnostic(shutdownOptions.cause); diff --git a/src/daemon/session-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/session-close-lifecycle-teardown.ts b/src/daemon/session-lifecycle/internal/session-close-lifecycle-teardown.ts index 1ce108ec07..4dd4fe6c77 100644 --- a/src/daemon/session-lifecycle/internal/session-close-lifecycle-teardown.ts +++ b/src/daemon/session-lifecycle/internal/session-close-lifecycle-teardown.ts @@ -37,8 +37,7 @@ export type SessionCloseTeardownResult = Readonly<{ /** Runs owned resources, native close, native hint cleanup, and final lifecycle disposal. */ export async function runSessionCloseTeardown(params: { req: DaemonRequest; - session: SessionState; - sessionName: string; + ref: SessionRef; logPath: string; sessionStore: SessionStore; lifecycle: CloseRuntime | CloseRuntimeWithRuntimeHintClear; @@ -56,8 +55,7 @@ export async function runSessionCloseTeardown(params: { }): Promise { const { req, - session, - sessionName, + ref, logPath, sessionStore, lifecycle, @@ -67,6 +65,8 @@ export async function runSessionCloseTeardown(params: { dispatchTargetedPlatformClose, finalizeOrdinaryCloseScript, } = params; + const { address: sessionName } = ref; + const session = sessionStore.requireCurrent(ref); const attemptCleanup = async ( step: string, run: () => Promise, @@ -86,7 +86,7 @@ export async function runSessionCloseTeardown(params: { }); const configuredRuntimeHints = sessionStore.getRuntimeHints(sessionName); await stopBestEffortSessionResources( - { address: sessionName, session }, + ref, sessionStore, attemptCleanup, params.platformResourceCleanup, diff --git a/src/daemon/session-lifecycle/internal/session-close.ts b/src/daemon/session-lifecycle/internal/session-close.ts index 889cc5016b..9d2f95dd9b 100644 --- a/src/daemon/session-lifecycle/internal/session-close.ts +++ b/src/daemon/session-lifecycle/internal/session-close.ts @@ -2,7 +2,7 @@ import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { AppError, normalizeError } from '@agent-device/kernel/errors'; import type { LeaseLifecycleProvider, TargetShutdownResult } from '@agent-device/contracts/device'; import type { DaemonRequest, DaemonResponse } from '../../daemon-request.ts'; -import type { SessionState } from '../../session-state.ts'; +import type { SessionRef, SessionState } from '../../session-state.ts'; import { SessionStore } from '../../session-store.ts'; import { successText, withSuccessText } from '@agent-device/kernel/success-text'; import { resolveCommandDevice } from '../../session-device-resolution.ts'; @@ -193,8 +193,8 @@ export async function handleSessionCloseCommands( params: SessionCloseCommandInput, ): Promise { const { req, sessionName, logPath, sessionStore, leaseRegistry, leaseLifecycleProvider } = params; - const session = sessionStore.get(sessionName); - if (!session) { + const ref = sessionStore.lookup(sessionName); + if (!ref) { return await closeWithoutSession({ req, logPath, @@ -202,6 +202,7 @@ export async function handleSessionCloseCommands( bindDevice: params.bindDevice, }); } + const session = ref.session; assertTerminalRecordingCloseAllowed(req, session); const app = req.positionals?.[0]; if (req.internal?.closeAppOnly === true && !app) { @@ -246,8 +247,7 @@ export async function handleSessionCloseCommands( if ('response' in repair) return repair.response; const closed = await runCloseTeardownAndRelease({ req, - session, - sessionName, + ref, logPath, sessionStore, leaseRegistry, @@ -281,8 +281,7 @@ type SessionCloseFinalization = // lease release keeps the session retryable instead (`{kind:'response'}`). async function runCloseTeardownAndRelease(params: { req: DaemonRequest; - session: SessionState; - sessionName: string; + ref: SessionRef; logPath: string; sessionStore: SessionStore; leaseRegistry: LeaseRegistry; @@ -294,8 +293,7 @@ async function runCloseTeardownAndRelease(params: { }): Promise { const { req, - session, - sessionName, + ref, logPath, sessionStore, leaseRegistry, @@ -303,11 +301,11 @@ async function runCloseTeardownAndRelease(params: { lifecycle, clearRuntimeHints, } = params; + const { address: sessionName, session } = ref; const cleanupFailures: SessionCleanupFailure[] = []; const { platformCloseError, saveScriptError, shutdownResult } = await runSessionCloseTeardown({ req, - session, - sessionName, + ref, logPath, sessionStore, lifecycle, diff --git a/src/daemon/session-recovery-hints.test.ts b/src/daemon/session-recovery-hints.test.ts index c1f0930fa4..15aceaa94d 100644 --- a/src/daemon/session-recovery-hints.test.ts +++ b/src/daemon/session-recovery-hints.test.ts @@ -1,3 +1,4 @@ +import { makeStoredSessionRef } from '../__tests__/test-utils/store-factory.ts'; import { test, expect } from 'vitest'; import { buildSessionRecoveryHint } from './session-recovery-hints.ts'; import type { SessionRef, SessionState } from './session-state.ts'; @@ -12,9 +13,8 @@ import { IOS_SIMULATOR } from '../__tests__/test-utils/device-fixtures.ts'; const SCOPED_ADDRESS = 'cwd:8bea844ab16aa9b3:default'; function scopedRef(overrides: Partial = {}): SessionRef { - return { - address: SCOPED_ADDRESS, - session: { + return makeStoredSessionRef( + { name: 'default', sessionScope: { kind: 'cwd', id: '8bea844ab16aa9b3' }, device: IOS_SIMULATOR, @@ -22,7 +22,8 @@ function scopedRef(overrides: Partial = {}): SessionRef { actions: [], ...overrides, }, - }; + SCOPED_ADDRESS, + ); } test('device-in-use recovery names the address --session accepts, not the public name', () => { @@ -55,10 +56,7 @@ test('a recording session recovery uses the address for both close and record st test('an explicitly named session addresses itself unchanged', () => { const hint = buildSessionRecoveryHint( - { - address: 'checkout', - session: { ...scopedRef().session, name: 'checkout', sessionScope: undefined }, - }, + makeStoredSessionRef({ ...scopedRef().session, name: 'checkout', sessionScope: undefined }), 'device-in-use', ); @@ -81,10 +79,7 @@ test('a device or target conflict does not offer a platform session it cannot an test('selector-conflict recovery offers no platform session to a hand-named session', () => { const hint = buildSessionRecoveryHint( - { - address: 'checkout', - session: { ...scopedRef().session, name: 'checkout', sessionScope: undefined }, - }, + makeStoredSessionRef({ ...scopedRef().session, name: 'checkout', sessionScope: undefined }), 'selector-conflict', ); diff --git a/src/daemon/session-state.ts b/src/daemon/session-state.ts index 8e3350ff04..bf5ee40bb8 100644 --- a/src/daemon/session-state.ts +++ b/src/daemon/session-state.ts @@ -108,10 +108,11 @@ export type PostGestureStabilization = { * target takes this pair rather than a bare record, so it cannot be handed a session whose address * was never resolved. */ -export type SessionRef = { +export type SessionRef = Readonly<{ address: string; session: SessionState; -}; + lifetime: object; +}>; export type SessionState = { name: string; diff --git a/src/daemon/session-store.ts b/src/daemon/session-store.ts index e31c66403a..9e7b1c2d8d 100644 --- a/src/daemon/session-store.ts +++ b/src/daemon/session-store.ts @@ -1,5 +1,6 @@ import path from 'node:path'; import fs from 'node:fs'; +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'; @@ -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,67 @@ 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; + } + + publish(address: string, session: SessionState): SessionRef { + if (!this.acceptingSessions) { + throw new AppError('COMMAND_FAILED', 'Daemon is shutting down', { + reason: 'daemon_shutting_down', + session: address, + }); + } + if (this.sessions.has(address)) { + throw new AppError('COMMAND_FAILED', 'Session address is already occupied', { + reason: 'session_address_occupied', + session: 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; + } + + 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 +140,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 +150,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 +164,21 @@ export class SessionStore { * falls back to `SessionState.name` (#2031/#1394). */ lookup(address: string): SessionRef | undefined { - const session = this.sessions.get(address); - return session ? { address, session } : undefined; + const entry = this.sessions.get(address); + return entry ? this.captureRef(address, entry) : undefined; } /** The session currently bound to `deviceId`, with its address, or `undefined` if none is. */ findByDevice(deviceId: string): SessionRef | undefined { - for (const [address, session] of this.sessions) { - if (session.device.id === deviceId) return { address, session }; + for (const [address, entry] of this.sessions) { + if (entry.current.device.id === deviceId) return this.captureRef(address, entry); } return undefined; } /** Every live session with its address, for surfaces that must report what `--session` accepts. */ listRefs(): SessionRef[] { - return Array.from(this.sessions, ([address, session]) => ({ address, session })); + return Array.from(this.sessions, ([address, entry]) => this.captureRef(address, entry)); } getRuntimeHints(name: string): SessionRuntimeHints | undefined { @@ -298,7 +362,7 @@ export class SessionStore { * never built, and its own `createdAt` already starts that session's deadline clock. */ noteSessionActivity(address: string, atMs: number = Date.now()): void { - const session = this.sessions.get(address); + const session = this.get(address); if (!session) return; session.lastActivityAtMs = atMs; } @@ -415,8 +479,8 @@ export class SessionStore { * public session name, while the map key may include cwd/tenant isolation. */ resolveStoredSessionName(session: SessionState): string { - for (const [name, value] of this.sessions) { - if (value === session) return name; + for (const [name, entry] of this.sessions) { + if (entry.current === session) return name; } return session.name; } From 530c6e976404bb5006576c7b73073b68b4fcc334 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 06:33:56 +0200 Subject: [PATCH 17/44] fix: bind capture adoption to session lifetimes --- .../audio-probe-session-resource.test.ts | 9 +- .../durable-capture-resource.fixtures.ts | 5 ++ .../screen-recording-boundary-faults.test.ts | 6 ++ .../screen-recording-session-resource.test.ts | 32 ++++--- .../__tests__/session-store.fixtures.ts | 12 +-- .../audio-probe-session-resource.ts | 9 +- .../durable-capture-resource.ts | 10 +-- .../perf-capture-session-resource.ts | 9 +- .../screen-recording-session-resource.ts | 5 +- .../src/durable-capture/adoption.ts | 85 ++++++++++--------- .../src/durable-capture/definition.ts | 33 ++++--- .../durable-capture.fixtures.ts | 12 ++- .../session-binding.fixtures.ts | 45 ++++++++++ .../src/recording/failed-finish.test.ts | 31 +++++-- .../app-log-session-resource.test.ts | 74 +++++++++++++++- .../perf-capture-session-resource.test.ts | 5 +- .../screen-recording-session-binding.test.ts | 60 +++++++++++++ .../__tests__/session-capture-binding.test.ts | 72 ++++++++++++++++ src/daemon/app-log-session-resource.ts | 35 +++++--- src/daemon/audio-probe-session-binding.ts | 12 +++ src/daemon/handlers/record-runtime.ts | 35 +++++--- src/daemon/perf-capture-session-binding.ts | 12 +++ .../screen-recording-session-binding.ts | 42 +++++++++ src/daemon/session-capture-binding.ts | 53 ++++++++++++ .../internal/__tests__/session-logs.test.ts | 2 +- .../internal/session-audio.ts | 32 +++---- .../internal/session-observability.ts | 21 +++-- .../internal/session-perf-runtime.ts | 19 +++-- src/daemon/session-store.ts | 10 ++- 29 files changed, 618 insertions(+), 169 deletions(-) create mode 100644 packages/capture-kit/src/durable-capture/session-binding.fixtures.ts create mode 100644 src/daemon/__tests__/screen-recording-session-binding.test.ts create mode 100644 src/daemon/__tests__/session-capture-binding.test.ts create mode 100644 src/daemon/audio-probe-session-binding.ts create mode 100644 src/daemon/perf-capture-session-binding.ts create mode 100644 src/daemon/screen-recording-session-binding.ts create mode 100644 src/daemon/session-capture-binding.ts diff --git a/packages/capture-kit/src/capture-admission/__tests__/audio-probe-session-resource.test.ts b/packages/capture-kit/src/capture-admission/__tests__/audio-probe-session-resource.test.ts index 073c33419b..359fb5f545 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, 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..e022419770 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'; @@ -72,6 +73,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__/screen-recording-boundary-faults.test.ts b/packages/capture-kit/src/capture-admission/__tests__/screen-recording-boundary-faults.test.ts index 775f5a3fae..dfab3f92e8 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'; @@ -217,9 +218,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..a64497c19d 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, @@ -103,7 +106,10 @@ test('screen recording persists durable truth before adopting only handle and en 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' }); + ).resolves.toMatchObject({ + backend: 'android', + outPath: '/tmp/recording.mp4', + }); expect(finish).toHaveBeenCalledOnce(); expect(sessionStore.get(sessionName)?.screenRecording).toBeUndefined(); }); @@ -115,6 +121,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 +160,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, @@ -182,6 +190,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 +228,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,7 +249,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, + session: sessionStore.get(sessionName) ?? session, sessionName, sessionStore, }); 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..ab0ccd73e0 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,13 +1,13 @@ 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'; -export type CaptureAdmissionSessionStore = DurableCaptureSessionStore & - Readonly<{ - get(name: string): S | undefined; - sessionsDir: string; - }>; +export type CaptureAdmissionSessionStore = Readonly<{ + set(name: string, session: S): void; + resolveSessionDir(name: string): string; + get(name: string): S | undefined; + sessionsDir: string; +}>; /** * The whole of the daemon `SessionStore` these admission modules ever address — `set`, 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..ddcd8037b0 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,7 +9,10 @@ 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, + DurableCaptureSessionStore, +} 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'; @@ -48,9 +51,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; 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..4dd4636244 100644 --- a/packages/capture-kit/src/capture-admission/durable-capture-resource.ts +++ b/packages/capture-kit/src/capture-admission/durable-capture-resource.ts @@ -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 }>; @@ -48,7 +48,7 @@ export function createDurableCaptureResource< S, >(definition: DurableCaptureResourceDefinition) { const sessionResourcePath = ( - sessionStore: DurableCaptureSessionStore, + sessionStore: Readonly<{ resolveSessionDir(name: string): string }>, sessionName: string, ): string => definition.store.resolvePath(sessionStore.resolveSessionDir(sessionName)); const recoveryParams = ( @@ -70,7 +70,7 @@ export function createDurableCaptureResource< }): ResourceOwnershipFence { return createNextDurableCaptureFence(definition, params); }, - adoptStarted(params: AdoptStartedSessionCaptureParams): Promise { + adoptStarted(params: AdoptStartedSessionCaptureParams): Promise { return adoptStartedDurableCapture( definition, { @@ -80,7 +80,7 @@ export function createDurableCaptureResource< else params.admissionLedger.blockUndurableCleanup(device, outcome.reason); }, }, - sessionResourcePath(params.sessionStore, params.sessionName), + definition.store.resolvePath(params.binding.sessionDir), ); }, finishLive(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..41546e034f 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,7 +9,10 @@ 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, + DurableCaptureSessionStore, +} 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'; @@ -46,9 +49,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; 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..2286e814e9 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,6 +16,7 @@ import type { StopObservation } from '@agent-device/contracts/recording-stop-obs import type { DeviceInfo } from '@agent-device/kernel/device'; import type { DurableCaptureRecoveryControl, + DurableCaptureSessionBinding, DurableCaptureSessionStore, } from '../durable-capture/index.ts'; import { createDurableCaptureResource } from './durable-capture-resource.ts'; @@ -50,9 +51,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; diff --git a/packages/capture-kit/src/durable-capture/adoption.ts b/packages/capture-kit/src/durable-capture/adoption.ts index 2390539458..eba37b023e 100644 --- a/packages/capture-kit/src/durable-capture/adoption.ts +++ b/packages/capture-kit/src/durable-capture/adoption.ts @@ -31,43 +31,41 @@ export async function adoptStartedDurableCapture< S, >( definition: DurableCaptureResourceDefinition, - params: AdoptStartedDurableCaptureParams, + 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 +82,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 +98,7 @@ function confirmFailedAdoptionTransition( - params: AdoptStartedDurableCaptureParams, +async function disposeFailedAdoption( + params: AdoptStartedDurableCaptureParams, state: AdoptionState, ): Promise { try { @@ -122,16 +120,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 +143,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 +183,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 +210,7 @@ function validateStartedEnvelope( return true; } -function emitCleanupDiagnostic( +function emitCleanupDiagnostic( definition: DurableCaptureRecordDefinition, - params: Pick, 'sessionName'>, + params: Pick, 'binding'>, primaryError: unknown, cleanupError: unknown, ): void { @@ -260,7 +263,7 @@ function emitCleanupDiagnostic = Readonly<{ - set(name: string, session: S): void; - resolveSessionDir(name: string): string; -}>; - export type DurableCaptureSessionResource = Readonly<{ handle: H; envelope: DurableResourceEnvelope; }>; +export type DurableCaptureSlotClearResult = 'cleared' | 'retired' | 'resource-changed'; + +export type DurableCaptureSessionBinding = Readonly<{ + address: string; + sessionDir: string; + read(): DurableCaptureSessionResource | undefined; + assertAdoptable(): void; + canPersist(): boolean; + adopt(resource: DurableCaptureSessionResource): void; + clear(expected: DurableCaptureSessionResource): DurableCaptureSlotClearResult; +}>; + +export type DurableCaptureSessionStore = Readonly<{ + set(name: string, session: S): void; + resolveSessionDir(name: string): string; +}>; + export type DurableCaptureSessionSlot = Readonly<{ read(session: S): DurableCaptureSessionResource | undefined; replace(session: S, resource: DurableCaptureSessionResource | undefined): S; @@ -86,11 +93,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..fcb88e857c 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 } from './session-binding.fixtures.ts'; import path from 'node:path'; import { vi, type Mock } from 'vitest'; import { @@ -15,7 +16,6 @@ import type { DurableCaptureFailedFinishPolicy, DurableCaptureResourceDefinition, DurableCaptureSessionResource, - DurableCaptureSessionStore, } from './definition.ts'; import { createDurableCaptureResourceStore, type DurableCaptureResourceStore } from './store.ts'; @@ -91,8 +91,9 @@ export function makeDurableCaptureContext( 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), + const sessionStore = { + get: (name: string) => sessions.get(name), + set: (name: string, next: TestCaptureSession) => void sessions.set(name, next), resolveSessionDir, }; const reportUndurableCleanup: Mock< @@ -100,6 +101,11 @@ export function makeDurableCaptureContext( > = vi.fn(); return { reportUndurableCleanup, + binding: makeCaptureSessionBinding( + sessionStore, + sessionName, + testCaptureDefinition.sessionSlot, + ), sessions, sessionsDir, resolveSessionDir, 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..611d9b9108 --- /dev/null +++ b/packages/capture-kit/src/durable-capture/session-binding.fixtures.ts @@ -0,0 +1,45 @@ +import { AppError } from '@agent-device/kernel/errors'; +import type { DurableCaptureSessionBinding, DurableCaptureSessionSlot } from './definition.ts'; + +export function makeCaptureSessionBinding( + store: Readonly<{ + get(name: string): S | undefined; + set(name: string, session: S): void; + resolveSessionDir(name: string): string; + }>, + address: string, + slot: DurableCaptureSessionSlot, +): DurableCaptureSessionBinding { + const requireSession = (): S => { + const session = store.get(address); + if (!session) 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.get(address); + return session === undefined ? undefined : slot.read(session); + }, + assertAdoptable, + canPersist: () => { + const session = store.get(address); + return session !== undefined && slot.read(session) === undefined; + }, + adopt: (resource) => { + assertAdoptable(); + store.set(address, slot.replace(requireSession(), resource)); + }, + clear: (expected) => { + const current = store.get(address); + if (!current) return 'retired'; + if (slot.read(current)?.handle !== expected.handle) return 'resource-changed'; + store.set(address, slot.replace(current, undefined)); + return 'cleared'; + }, + }); +} diff --git a/packages/platform-android/src/recording/failed-finish.test.ts b/packages/platform-android/src/recording/failed-finish.test.ts index 4c4984020c..d3f7afb0aa 100644 --- a/packages/platform-android/src/recording/failed-finish.test.ts +++ b/packages/platform-android/src/recording/failed-finish.test.ts @@ -13,7 +13,6 @@ import { createDurableCaptureResourceStore, finishLiveDurableCapture, type DurableCaptureResourceDefinition, - type DurableCaptureSessionStore, } from '@agent-device/capture-kit/durable-capture'; import { mkdtempForTestSync } from '../__tests__/test-utils/tmp-dir.ts'; import { androidRecordingDevice, recordingHost, recordingInput } from './fixtures.ts'; @@ -150,20 +149,36 @@ async function adoptAndroidRecording(params: { }; 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 resourcePath = store.resolvePath(sessionStore.resolveSessionDir(params.sessionName)); + 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(binding.sessionDir); await adoptStartedDurableCapture( definition, { reportUndurableCleanup: () => {}, - session, - sessionName: params.sessionName, - sessionStore, + binding, device: androidRecordingDevice, owner: params.owner, fence: params.envelope.fence, diff --git a/src/daemon/__tests__/app-log-session-resource.test.ts b/src/daemon/__tests__/app-log-session-resource.test.ts index 84a3add4f2..eb9402d3ba 100644 --- a/src/daemon/__tests__/app-log-session-resource.test.ts +++ b/src/daemon/__tests__/app-log-session-resource.test.ts @@ -88,7 +88,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( @@ -315,6 +315,77 @@ 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, + }); +}); + function makeContext( device: DeviceInfo = { platform: 'android', @@ -335,6 +406,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__/perf-capture-session-resource.test.ts b/src/daemon/__tests__/perf-capture-session-resource.test.ts index cd4010c381..6d7d4e8841 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 '../perf-capture-session-binding.ts'; import { beforeEach, expect, test, vi } from 'vitest'; import { localRuntimeOwner } from '@agent-device/contracts/platform-runtime'; import { AppError } from '@agent-device/kernel/errors'; @@ -112,9 +113,7 @@ test('a perf stop whose pull failed re-collects the device-side trace the first }); await adoptStartedPerfCapture({ admissionLedger: createPerfCaptureAdmissionLedger(), - session, - sessionName, - sessionStore, + binding: bindSessionPerfCapture(sessionStore, sessionStore.lookup(sessionName)!), device, owner: localRuntimeOwner('android'), fence, diff --git a/src/daemon/__tests__/screen-recording-session-binding.test.ts b/src/daemon/__tests__/screen-recording-session-binding.test.ts new file mode 100644 index 0000000000..520b6b3452 --- /dev/null +++ b/src/daemon/__tests__/screen-recording-session-binding.test.ts @@ -0,0 +1,60 @@ +import { expect, test, vi } from 'vitest'; +import { createScreenRecordingLiveHandle } from '@agent-device/capture-kit'; +import { PendingTransferGuard } from '@agent-device/contracts/async-lifecycle'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { makeRecordingSession } from './session-teardown.fixtures.ts'; +import { bindRecordOnlyScreenRecording } from '../screen-recording-session-binding.ts'; +import { createScreenRecordingAdmissionLedger } from '@agent-device/capture-kit/screen-recording-admission-ledger'; +import { + adoptStartedScreenRecording, + screenRecordingDurableResource, +} from '@agent-device/capture-kit/screen-recording-session-resource'; + +test('shutdown refuses draft publication while retaining unconfirmed recording cleanup evidence', async () => { + const store = makeSessionStore(); + const session = makeRecordingSession({ + name: 'draft', + sessionStore: store, + finish: async () => ({ status: 'cleanup-pending', reason: 'cleanup-unconfirmed' }), + }); + const { handle: initial, envelope } = session.screenRecording!; + const cleanup = vi.fn( + async () => ({ status: 'cleanup-pending', reason: 'cleanup-unconfirmed' }) as const, + ); + const handle = createScreenRecordingLiveHandle(initial.inspect(), { + finish: async () => ({ status: 'cleanup-pending', reason: 'cleanup-unconfirmed' }), + forceCleanup: cleanup, + }); + const draft = bindRecordOnlyScreenRecording(store, 'draft', { + ...session, + screenRecording: undefined, + }); + store.closeAdmission(); + await expect( + adoptStartedScreenRecording({ + binding: draft.binding, + admissionLedger: createScreenRecordingAdmissionLedger(), + device: session.device, + owner: envelope.owner, + fence: envelope.fence, + pendingHandle: new PendingTransferGuard(handle), + envelope, + throwIfCanceled: () => {}, + }), + ).rejects.toMatchObject({ details: { reason: 'daemon_shutting_down' } }); + expect(cleanup).toHaveBeenCalledOnce(); + expect(store.lookup('draft')).toBeUndefined(); + const record = screenRecordingDurableResource.store.read( + screenRecordingDurableResource.store.resolvePath(draft.binding.sessionDir), + ); + expect(record).toMatchObject({ + status: 'decoded', + envelope: { + lifecycle: 'open', + descriptor: envelope.descriptor, + metadata: { phase: 'cleanup-pending' }, + }, + }); + if (record.status !== 'decoded') throw new Error('Expected recovery evidence'); + expect(record.envelope.metadata?.runtimeContractInvalid).toBeUndefined(); +}); diff --git a/src/daemon/__tests__/session-capture-binding.test.ts b/src/daemon/__tests__/session-capture-binding.test.ts new file mode 100644 index 0000000000..41f5322d5b --- /dev/null +++ b/src/daemon/__tests__/session-capture-binding.test.ts @@ -0,0 +1,72 @@ +import { expect, test } from 'vitest'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { makeRecordingSession } from './session-teardown.fixtures.ts'; +import { bindSessionScreenRecording } from '../screen-recording-session-binding.ts'; + +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); + const newerFence = { + ...active, + envelope: { ...active.envelope, fence: { token: 'next', generation: 2 } }, + }; + 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/app-log-session-resource.ts b/src/daemon/app-log-session-resource.ts index e86d50f165..5c3993e19b 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; @@ -76,10 +77,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,7 +86,10 @@ 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: { @@ -110,15 +112,15 @@ export function forceCleanupSessionAppLog(params: { } 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 +133,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, }); } + +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/audio-probe-session-binding.ts b/src/daemon/audio-probe-session-binding.ts new file mode 100644 index 0000000000..b4eecb43c7 --- /dev/null +++ b/src/daemon/audio-probe-session-binding.ts @@ -0,0 +1,12 @@ +import { bindSessionCapture } from './session-capture-binding.ts'; +import type { SessionRef } from './session-state.ts'; +import type { SessionStore } from './session-store.ts'; + +export function bindSessionAudioProbe(sessionStore: SessionStore, ref: SessionRef) { + return bindSessionCapture(sessionStore, ref, { + read: (session) => session.audioProbe, + write: (audioProbe) => { + sessionStore.update(ref, { audioProbe }); + }, + }); +} diff --git a/src/daemon/handlers/record-runtime.ts b/src/daemon/handlers/record-runtime.ts index 758bd95bc4..1e63e684cb 100644 --- a/src/daemon/handlers/record-runtime.ts +++ b/src/daemon/handlers/record-runtime.ts @@ -30,7 +30,11 @@ 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, + bindSessionScreenRecording, +} from '../screen-recording-session-binding.ts'; import { recordSessionAction } from '../session-action-recorder.ts'; import { missingAppSessionResponse, @@ -80,17 +84,19 @@ 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, @@ -113,17 +119,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,6 +138,11 @@ 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; @@ -142,21 +154,20 @@ async function startRecording( ); 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), diff --git a/src/daemon/perf-capture-session-binding.ts b/src/daemon/perf-capture-session-binding.ts new file mode 100644 index 0000000000..53463cd8ab --- /dev/null +++ b/src/daemon/perf-capture-session-binding.ts @@ -0,0 +1,12 @@ +import { bindSessionCapture } from './session-capture-binding.ts'; +import type { SessionRef } from './session-state.ts'; +import type { SessionStore } from './session-store.ts'; + +export function bindSessionPerfCapture(sessionStore: SessionStore, ref: SessionRef) { + return bindSessionCapture(sessionStore, ref, { + read: (session) => session.perfCapture, + write: (perfCapture) => { + sessionStore.update(ref, { perfCapture }); + }, + }); +} diff --git a/src/daemon/screen-recording-session-binding.ts b/src/daemon/screen-recording-session-binding.ts new file mode 100644 index 0000000000..d032f819ba --- /dev/null +++ b/src/daemon/screen-recording-session-binding.ts @@ -0,0 +1,42 @@ +import { bindSessionCapture } from './session-capture-binding.ts'; +import type { SessionRef } from './session-state.ts'; +import type { SessionStore } from './session-store.ts'; + +export function bindSessionScreenRecording(sessionStore: SessionStore, ref: SessionRef) { + return bindSessionCapture(sessionStore, ref, { + read: (session) => session.screenRecording, + write: (screenRecording) => { + sessionStore.update(ref, { screenRecording }); + }, + }); +} + +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/session-capture-binding.ts b/src/daemon/session-capture-binding.ts new file mode 100644 index 0000000000..c3f809cf37 --- /dev/null +++ b/src/daemon/session-capture-binding.ts @@ -0,0 +1,53 @@ +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 { + 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: () => slot.read(sessionStore.resolveCurrent(ref) ?? ref.session), + assertAdoptable, + canPersist: () => { + const current = sessionStore.resolveCurrent(ref); + return current !== undefined && slot.read(current) === undefined; + }, + adopt: (resource) => { + assertAdoptable(); + slot.write(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); + return 'cleared'; + }, + }); +} 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/session-audio.ts b/src/daemon/session-observability/internal/session-audio.ts index a7e60b98c7..6d439e65b5 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, SessionState } from '../../session-state.ts'; +import { bindSessionAudioProbe } from '../../audio-probe-session-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,7 +69,7 @@ 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. @@ -110,12 +114,10 @@ async function startAudioProbe( await finishLiveAudioProbe({ intent: 'disposal', session, - sessionName: params.sessionName, + sessionName: ref.address, sessionStore: params.sessionStore, }); - 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( @@ -139,16 +141,14 @@ 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() }; } diff --git a/src/daemon/session-observability/internal/session-observability.ts b/src/daemon/session-observability/internal/session-observability.ts index 2171842c39..2e98bd25de 100644 --- a/src/daemon/session-observability/internal/session-observability.ts +++ b/src/daemon/session-observability/internal/session-observability.ts @@ -31,7 +31,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 +51,7 @@ export type SessionObservabilityCommandInput = { type ObservabilityInput = SessionObservabilityCommandInput; type LogsHandlerParams = Omit & { session: SessionState; + ref: SessionRef; bindDevice: BindDeviceRuntime; appLogAdmissionLedger: AppLogAdmissionLedger; }; @@ -143,12 +144,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 +307,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 }; } @@ -386,10 +388,8 @@ async function startSessionAppLog( }); await adoptStartedSessionAppLog({ admissionLedger: params.appLogAdmissionLedger, - session, - sessionName, + ref: params.ref, sessionStore, - resourcePath, device: session.device, owner, fence, @@ -400,8 +400,7 @@ async function startSessionAppLog( return { ok: true, data: { path: outputPath, started: true } }; } catch (error) { const normalized = recordSessionAppLogFailure({ - session, - sessionName, + ref: params.ref, sessionStore, error, }); @@ -431,7 +430,7 @@ function requireAudioSeams(params: ObservabilityInput): Parameters { - 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); @@ -89,7 +92,7 @@ export async function handlePerfRuntimeCommand( } return recordSuccessfulPerfResponse( params, - await executeAdmittedPerfPlan(params, session, admitted), + await executeAdmittedPerfPlan(bound, session, admitted), ); } catch (error) { return { ok: false, error: normalizeError(error) }; @@ -116,7 +119,7 @@ function recordSuccessfulPerfResponse( // the admission/runtime join this handler is meant to keep singular. // fallow-ignore-next-line complexity async function executeAdmittedPerfPlan( - params: PerfRuntimeHandlerParams, + params: PerfRuntimeHandlerParams & { ref: SessionRef }, session: SessionState, admission: AdmittedRuntimePlan>, ): Promise { @@ -183,7 +186,7 @@ async function executeAdmittedPerfPlan( } async function startPerfCapture( - params: PerfRuntimeHandlerParams, + params: PerfRuntimeHandlerParams & { ref: SessionRef }, session: SessionState, runtime: Readonly<{ owner: Parameters[0]['owner']; @@ -225,9 +228,7 @@ async function startPerfCapture( }); await adoptStartedPerfCapture({ admissionLedger: requirePerfCaptureAdmissionLedger(params), - session, - sessionName: params.sessionName, - sessionStore: params.sessionStore, + binding: bindSessionPerfCapture(params.sessionStore, params.ref), device: session.device, owner: runtime.owner, fence, diff --git a/src/daemon/session-store.ts b/src/daemon/session-store.ts index 9e7b1c2d8d..97e2d6dc4b 100644 --- a/src/daemon/session-store.ts +++ b/src/daemon/session-store.ts @@ -73,19 +73,27 @@ export class SessionStore { this.acceptingSessions = false; } - publish(address: string, session: SessionState): SessionRef { + 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); From bfffb65f017ce0a8289150bc5e00127ccde3cbf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 06:33:56 +0200 Subject: [PATCH 18/44] chore(gates): declare capture adoption field owners --- packages/capture-kit/src/durable-capture/index.ts | 1 + scripts/layering/session-resource-ownership.test.ts | 2 ++ scripts/layering/session-resource-ownership.ts | 7 +++++++ 3 files changed, 10 insertions(+) diff --git a/packages/capture-kit/src/durable-capture/index.ts b/packages/capture-kit/src/durable-capture/index.ts index 09013e180a..dc4349e202 100644 --- a/packages/capture-kit/src/durable-capture/index.ts +++ b/packages/capture-kit/src/durable-capture/index.ts @@ -14,6 +14,7 @@ export type { DurableCaptureRecordDefinition, DurableCaptureResourceDefinition, DurableCaptureSessionResource, + DurableCaptureSessionBinding, DurableCaptureSessionStore, } from './definition.ts'; export type { FinishRecoveredDurableCaptureParams } from './finish-recovered.ts'; diff --git a/scripts/layering/session-resource-ownership.test.ts b/scripts/layering/session-resource-ownership.test.ts index ffac560315..4b2e7fc2b8 100644 --- a/scripts/layering/session-resource-ownership.test.ts +++ b/scripts/layering/session-resource-ownership.test.ts @@ -19,6 +19,7 @@ test('session resources are constructed only by their durable domain owners', () appLogFailure: failure, audioProbe: audio, perfCapture: perf, + screenRecording: recording, });`, ], [ @@ -39,6 +40,7 @@ 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', ], ); }); diff --git a/scripts/layering/session-resource-ownership.ts b/scripts/layering/session-resource-ownership.ts index 4990fdef9e..579dd3d637 100644 --- a/scripts/layering/session-resource-ownership.ts +++ b/scripts/layering/session-resource-ownership.ts @@ -29,10 +29,17 @@ const RESOURCE_OWNERS: Readonly>> = { 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/audio-probe-session-binding.ts', + 'src/daemon/session-state.ts', + ]), + screenRecording: new Set([ + 'packages/capture-kit/src/capture-admission/screen-recording-session-resource.ts', + 'src/daemon/screen-recording-session-binding.ts', 'src/daemon/session-state.ts', ]), perfCapture: new Set([ 'packages/capture-kit/src/capture-admission/perf-capture-session-resource.ts', + 'src/daemon/perf-capture-session-binding.ts', 'src/daemon/session-state.ts', ]), }; From ebecbda42605231121388f364ebff68d35140116 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 16:46:15 +0200 Subject: [PATCH 19/44] fix: name the capture clear outcome inline so session bindings emit declarations Daemon session bindings inferred a binding type whose clear result was an alias the durable-capture entry never exported, so declaration emit failed with TS2883. --- packages/capture-kit/src/durable-capture/definition.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/capture-kit/src/durable-capture/definition.ts b/packages/capture-kit/src/durable-capture/definition.ts index adf33b18e2..4b0aba2fdb 100644 --- a/packages/capture-kit/src/durable-capture/definition.ts +++ b/packages/capture-kit/src/durable-capture/definition.ts @@ -14,8 +14,6 @@ export type DurableCaptureSessionResource; }>; -export type DurableCaptureSlotClearResult = 'cleared' | 'retired' | 'resource-changed'; - export type DurableCaptureSessionBinding = Readonly<{ address: string; sessionDir: string; @@ -23,7 +21,7 @@ export type DurableCaptureSessionBinding): void; - clear(expected: DurableCaptureSessionResource): DurableCaptureSlotClearResult; + clear(expected: DurableCaptureSessionResource): 'cleared' | 'retired' | 'resource-changed'; }>; export type DurableCaptureSessionStore = Readonly<{ From cbf0a6e7d5665b5081200e8337cae72af7e514a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 18:45:46 +0200 Subject: [PATCH 20/44] perf: load app-log cleanup on demand in session teardown Binding app-log capture through the session capture binding pulled that module into session-teardown's eager closure. Teardown now imports the app-log resource only when a session has an app log to stop. --- src/daemon/session-teardown.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/daemon/session-teardown.ts b/src/daemon/session-teardown.ts index 377be3fb2b..2f051b2057 100644 --- a/src/daemon/session-teardown.ts +++ b/src/daemon/session-teardown.ts @@ -3,7 +3,6 @@ import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { cleanupRetainedMaterializedPathsForSession } from './materialized-path-registry.ts'; import type { SessionState } from './session-state.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'; @@ -18,6 +17,7 @@ export async function stopSessionAppLog(params: { }): Promise { const { session, sessionName, sessionStore } = params; if (!session.appLog) return; + const { forceCleanupSessionAppLog } = await import('./app-log-session-resource.ts'); await forceCleanupSessionAppLog({ session, sessionName, From cebb742224a44758c8be70e431206847496d126b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 06:50:44 +0200 Subject: [PATCH 21/44] fix: finish session captures through lifetime bindings --- .../audio-probe-session-resource.test.ts | 4 +- .../durable-capture-resource.fixtures.ts | 11 +-- .../durable-capture-resource.test.ts | 4 +- .../screen-recording-boundary-faults.test.ts | 7 +- .../screen-recording-session-resource.test.ts | 12 +-- .../audio-probe-session-resource.ts | 17 +--- .../durable-capture-resource.ts | 33 +++----- .../perf-capture-session-resource.ts | 17 +--- .../screen-recording-session-resource.ts | 13 +--- .../screen-recording-stop-recovery.ts | 8 +- .../src/durable-capture/adoption.ts | 4 +- .../src/durable-capture/definition.ts | 19 ----- .../durable-capture.fixtures.ts | 22 ++---- .../session-binding.fixtures.ts | 7 +- .../src/durable-capture/transitions.test.ts | 24 ++---- .../src/durable-capture/transitions.ts | 42 +++------- .../src/recording/failed-finish.test.ts | 40 ++++------ .../app-log-session-resource.test.ts | 64 ++++++++++++++- .../__tests__/daemon-runtime-app-log.test.ts | 2 +- .../perf-capture-session-resource.test.ts | 4 +- src/daemon/app-log-session-resource.ts | 26 +++---- src/daemon/handlers/record-runtime.ts | 22 ++++-- src/daemon/request-execution-scope.ts | 5 +- .../daemon-runtime-device-claims.test.ts | 4 +- .../daemon-runtime-recording-teardown.test.ts | 4 +- .../daemon-runtime-web-close-teardown.test.ts | 6 +- src/daemon/server/daemon-runtime.ts | 29 +++---- .../session-teardown-resources.test.ts | 37 +++++---- .../session-close-lifecycle-teardown.ts | 20 ++--- .../internal/session-audio.ts | 31 +++----- .../internal/session-observability.ts | 8 +- .../internal/session-perf-runtime.ts | 11 +-- src/daemon/session-teardown.ts | 78 +++++++------------ 33 files changed, 258 insertions(+), 377 deletions(-) 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 359fb5f545..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 @@ -104,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 e022419770..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 @@ -31,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, 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 dfab3f92e8..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 @@ -188,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, 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 a64497c19d..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 @@ -104,9 +104,7 @@ 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({ + await expect(finishLiveScreenRecording({ intent: 'capture', binding })).resolves.toMatchObject({ backend: 'android', outPath: '/tmp/recording.mp4', }); @@ -171,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); @@ -249,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: sessionStore.get(sessionName) ?? session, - sessionName, - sessionStore, + binding, }); }; 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 ddcd8037b0..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,29 +9,20 @@ import type { RuntimeOwnerRef, } from '@agent-device/contracts/platform-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import type { - DurableCaptureSessionBinding, - 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, @@ -63,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 4dd4636244..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'; @@ -34,19 +34,12 @@ 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: Readonly<{ resolveSessionDir(name: string): string }>, sessionName: string, @@ -84,27 +77,23 @@ export function createDurableCaptureResource< ); }, 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 41546e034f..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,29 +9,20 @@ import type { RuntimeOwnerRef, } from '@agent-device/contracts/platform-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import type { - DurableCaptureSessionBinding, - 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', @@ -61,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 2286e814e9..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 @@ -17,27 +17,20 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import type { DurableCaptureRecoveryControl, DurableCaptureSessionBinding, - DurableCaptureSessionStore, } 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. @@ -63,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.ts b/packages/capture-kit/src/durable-capture/adoption.ts index eba37b023e..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,9 +27,8 @@ export async function adoptStartedDurableCapture< K extends string, H extends LiveResourceHandle, C, - S, >( - definition: DurableCaptureResourceDefinition, + definition: DurableCaptureRecordDefinition, params: AdoptStartedDurableCaptureParams, resourcePath: string, ): Promise { diff --git a/packages/capture-kit/src/durable-capture/definition.ts b/packages/capture-kit/src/durable-capture/definition.ts index 4b0aba2fdb..0b947fdae9 100644 --- a/packages/capture-kit/src/durable-capture/definition.ts +++ b/packages/capture-kit/src/durable-capture/definition.ts @@ -1,6 +1,5 @@ import type { JsonObject } from '@agent-device/contracts/client'; import type { DurableResourceEnvelope } from '@agent-device/contracts/durable-resource-envelope'; -import type { LiveResourceHandle } from '@agent-device/contracts/durable-resource'; import type { PendingTransferGuard } from '@agent-device/contracts/async-lifecycle'; import type { ResourceOwnershipFence, @@ -24,16 +23,6 @@ export type DurableCaptureSessionBinding): 'cleared' | 'retired' | 'resource-changed'; }>; -export type DurableCaptureSessionStore = Readonly<{ - set(name: string, session: S): void; - resolveSessionDir(name: string): string; -}>; - -export type DurableCaptureSessionSlot = Readonly<{ - read(session: S): DurableCaptureSessionResource | undefined; - replace(session: S, resource: DurableCaptureSessionResource | undefined): S; -}>; - /** * What a failed capture finish may do to a kind's material (ADR 0024 rule 6). A kind declares here * whether the shared coordinator's failed capture finish still earns a forced cleanup; a disposal @@ -75,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. 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 fcb88e857c..363b83a45c 100644 --- a/packages/capture-kit/src/durable-capture/durable-capture.fixtures.ts +++ b/packages/capture-kit/src/durable-capture/durable-capture.fixtures.ts @@ -14,7 +14,7 @@ import { mkdtempForTestSync } from '../tmp-dir.fixtures.ts'; import type { DurableCaptureCleanupOutcome, DurableCaptureFailedFinishPolicy, - DurableCaptureResourceDefinition, + DurableCaptureRecordDefinition, DurableCaptureSessionResource, } 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, @@ -101,11 +92,10 @@ export function makeDurableCaptureContext( > = vi.fn(); return { reportUndurableCleanup, - binding: makeCaptureSessionBinding( - sessionStore, - sessionName, - testCaptureDefinition.sessionSlot, - ), + binding: makeCaptureSessionBinding(sessionStore, sessionName, { + read: (session) => session.capture, + replace: (session, capture) => ({ ...session, capture }), + }), sessions, sessionsDir, resolveSessionDir, diff --git a/packages/capture-kit/src/durable-capture/session-binding.fixtures.ts b/packages/capture-kit/src/durable-capture/session-binding.fixtures.ts index 611d9b9108..8c37b7be49 100644 --- a/packages/capture-kit/src/durable-capture/session-binding.fixtures.ts +++ b/packages/capture-kit/src/durable-capture/session-binding.fixtures.ts @@ -1,5 +1,5 @@ import { AppError } from '@agent-device/kernel/errors'; -import type { DurableCaptureSessionBinding, DurableCaptureSessionSlot } from './definition.ts'; +import type { DurableCaptureSessionBinding, DurableCaptureSessionResource } from './definition.ts'; export function makeCaptureSessionBinding( store: Readonly<{ @@ -8,7 +8,10 @@ export function makeCaptureSessionBinding, address: string, - slot: DurableCaptureSessionSlot, + slot: Readonly<{ + read(session: S): DurableCaptureSessionResource | undefined; + replace(session: S, resource: DurableCaptureSessionResource | undefined): S; + }>, ): DurableCaptureSessionBinding { const requireSession = (): S => { const session = store.get(address); diff --git a/packages/capture-kit/src/durable-capture/transitions.test.ts b/packages/capture-kit/src/durable-capture/transitions.test.ts index 5085197ca1..f691674c53 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, diff --git a/packages/capture-kit/src/durable-capture/transitions.ts b/packages/capture-kit/src/durable-capture/transitions.ts index 8e3a2590e2..b6de89d1fb 100644 --- a/packages/capture-kit/src/durable-capture/transitions.ts +++ b/packages/capture-kit/src/durable-capture/transitions.ts @@ -12,8 +12,7 @@ import { withDurableCaptureResourceFence, type DurableCaptureResourceFenceLease import type { DurableCaptureFinishIntent, DurableCaptureRecordDefinition, - DurableCaptureResourceDefinition, - DurableCaptureSessionStore, + DurableCaptureSessionBinding, } from './definition.ts'; import { capitalizeDurableCaptureLabel, durableCaptureDiagnosticPrefix } from './labels.ts'; @@ -21,18 +20,15 @@ export async function finishLiveDurableCapture< K extends string, H extends LiveResourceHandle, C, - S, >( - definition: DurableCaptureResourceDefinition, + definition: DurableCaptureRecordDefinition, params: { - session: S; - sessionName: string; - sessionStore: DurableCaptureSessionStore; + binding: DurableCaptureSessionBinding; intent: DurableCaptureFinishIntent; }, resourcePath: string, ): Promise { - const active = definition.sessionSlot.read(params.session); + const active = params.binding.read(); if (!active) throw new AppError('INVALID_ARGS', definition.messages.noActive); try { const result = await finishDurableCaptureHandle(definition, { @@ -41,12 +37,12 @@ export async function finishLiveDurableCapture< resourcePath, intent: params.intent, }); - clearLiveSlot(definition, params); + params.binding.clear(active); return result; } catch (error) { const record = definition.store.read(resourcePath); if (record.status === 'decoded' && record.envelope.lifecycle === 'completed') { - clearLiveSlot(definition, params); + params.binding.clear(active); } throw error; } @@ -186,16 +182,6 @@ function emitFailedFinishCleanupDiagnostic( }); } -function clearLiveSlot, C, S>( - definition: DurableCaptureResourceDefinition, - params: { session: S; sessionName: string; sessionStore: DurableCaptureSessionStore }, -): void { - params.sessionStore.set( - params.sessionName, - definition.sessionSlot.replace(params.session, undefined), - ); -} - function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } @@ -204,17 +190,14 @@ export async function forceCleanupLiveDurableCapture< K extends string, H extends LiveResourceHandle, C, - S, >( - definition: DurableCaptureResourceDefinition, + definition: DurableCaptureRecordDefinition, params: { - session: S; - sessionName?: string; - sessionStore?: DurableCaptureSessionStore; + binding: DurableCaptureSessionBinding; resourcePath: string; }, ): Promise { - const active = definition.sessionSlot.read(params.session); + const active = params.binding.read(); if (!active) return; const outcome = await withDurableCaptureResourceFence({ store: definition.store, @@ -228,12 +211,7 @@ export async function forceCleanupLiveDurableCapture< }, }); requireConfirmedDurableCaptureCleanup(definition, outcome); - if (params.sessionStore && params.sessionName) { - params.sessionStore.set( - params.sessionName, - definition.sessionSlot.replace(params.session, undefined), - ); - } + params.binding.clear(active); } export function transitionCleanupOutcome( diff --git a/packages/platform-android/src/recording/failed-finish.test.ts b/packages/platform-android/src/recording/failed-finish.test.ts index d3f7afb0aa..38f9be8955 100644 --- a/packages/platform-android/src/recording/failed-finish.test.ts +++ b/packages/platform-android/src/recording/failed-finish.test.ts @@ -12,7 +12,7 @@ import { adoptStartedDurableCapture, createDurableCaptureResourceStore, finishLiveDurableCapture, - type DurableCaptureResourceDefinition, + 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'; @@ -113,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: { @@ -127,26 +127,18 @@ 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 = { @@ -193,9 +185,7 @@ async function adoptAndroidRecording(params: { finishLiveDurableCapture( definition, { - session, - sessionName: params.sessionName, - sessionStore, + binding, intent: 'capture', }, resourcePath, diff --git a/src/daemon/__tests__/app-log-session-resource.test.ts b/src/daemon/__tests__/app-log-session-resource.test.ts index eb9402d3ba..2a20ed6a0c 100644 --- a/src/daemon/__tests__/app-log-session-resource.test.ts +++ b/src/daemon/__tests__/app-log-session-resource.test.ts @@ -296,7 +296,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); @@ -386,6 +385,69 @@ test('late adoption disposes its pending handle without overwriting a successor }); }); +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', 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__/perf-capture-session-resource.test.ts b/src/daemon/__tests__/perf-capture-session-resource.test.ts index 6d7d4e8841..ecd14eb954 100644 --- a/src/daemon/__tests__/perf-capture-session-resource.test.ts +++ b/src/daemon/__tests__/perf-capture-session-resource.test.ts @@ -127,9 +127,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: bindSessionPerfCapture(sessionStore, sessionStore.lookup(sessionName)!), }); await expect(stop()).rejects.toBe(pullFailure); diff --git a/src/daemon/app-log-session-resource.ts b/src/daemon/app-log-session-resource.ts index 5c3993e19b..89335eebcb 100644 --- a/src/daemon/app-log-session-resource.ts +++ b/src/daemon/app-log-session-resource.ts @@ -31,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, @@ -93,22 +88,23 @@ export function adoptStartedSessionAppLog(params: { } 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: { diff --git a/src/daemon/handlers/record-runtime.ts b/src/daemon/handlers/record-runtime.ts index 1e63e684cb..ad8cfc5b16 100644 --- a/src/daemon/handlers/record-runtime.ts +++ b/src/daemon/handlers/record-runtime.ts @@ -102,7 +102,13 @@ async function handleRecordCommandUnsafe( 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) { @@ -230,6 +236,7 @@ function recordingAppIdentity( async function stopRecording( params: RecordRuntimeHandlerParams, session: SessionState, + ref: SessionRef | undefined, kind: 'stop-live' | 'stop-recovery', needsReadiness: boolean, ): Promise { @@ -240,15 +247,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; @@ -263,16 +268,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/request-execution-scope.ts b/src/daemon/request-execution-scope.ts index 2fcf80bc7c..b19eaa3bcd 100644 --- a/src/daemon/request-execution-scope.ts +++ b/src/daemon/request-execution-scope.ts @@ -418,12 +418,13 @@ async function teardownExpiredSession(params: { platformCleanup: PlatformResourceCleanup; }): Promise { const { session, sessionName, sessionStore, inspectFacts, bindDevice, platformCleanup } = params; + const ref = sessionStore.lookup(sessionName); + if (!ref) return; let primaryError: unknown; try { await teardownSessionResources({ appLog: 'run', - session, - sessionName, + ref, sessionStore, platformCleanup, }); diff --git a/src/daemon/server/daemon-runtime-device-claims.test.ts b/src/daemon/server/daemon-runtime-device-claims.test.ts index 97586767cc..05bfb11e22 100644 --- a/src/daemon/server/daemon-runtime-device-claims.test.ts +++ b/src/daemon/server/daemon-runtime-device-claims.test.ts @@ -47,7 +47,7 @@ test('finalizes provider state but does not clear a claim after shutdown teardow const afterSuccessfulTeardown = vi.fn(async () => {}); await teardownDaemonSessionForShutdown({ - session, + ref: sessionStore.lookup(session.name)!, sessionStore, stderr: { write: () => {} }, beforeDelete, @@ -67,7 +67,7 @@ test('finalizes provider state but does not clear a claim after shutdown teardow const afterSuccessfulTeardown = vi.fn(async () => {}); const teardown = teardownDaemonSessionForShutdown({ - session, + ref: sessionStore.lookup(session.name)!, sessionStore, stderr: { write: () => {} }, beforeDelete, diff --git a/src/daemon/server/daemon-runtime-recording-teardown.test.ts b/src/daemon/server/daemon-runtime-recording-teardown.test.ts index d0d9897465..f4eae37115 100644 --- a/src/daemon/server/daemon-runtime-recording-teardown.test.ts +++ b/src/daemon/server/daemon-runtime-recording-teardown.test.ts @@ -35,7 +35,7 @@ test('daemon shutdown awaits durable recording finalization inside its extended const stderrChunks: string[] = []; const teardown = teardownDaemonSessionForShutdown({ - session, + ref: sessionStore.lookup(session.name)!, sessionStore, stderr: { write: (chunk) => stderrChunks.push(chunk) }, }); @@ -72,7 +72,7 @@ test('daemon shutdown resolves durable recording resources through the effective const stderrChunks: string[] = []; await teardownDaemonSessionForShutdown({ - session, + ref: sessionStore.lookup(effectiveSessionName)!, sessionStore, stateDir: root, stderr: { write: (chunk) => stderrChunks.push(chunk) }, diff --git a/src/daemon/server/daemon-runtime-web-close-teardown.test.ts b/src/daemon/server/daemon-runtime-web-close-teardown.test.ts index dbd971d386..64e8d377de 100644 --- a/src/daemon/server/daemon-runtime-web-close-teardown.test.ts +++ b/src/daemon/server/daemon-runtime-web-close-teardown.test.ts @@ -49,7 +49,7 @@ test('daemon shutdown awaits a slow web close inside its extended budget', async const stderrChunks: string[] = []; const teardown = teardownDaemonSessionForShutdown({ - session, + ref: sessionStore.lookup(session.name)!, sessionStore, stateDir: root, stderr: { write: (chunk) => stderrChunks.push(chunk) }, @@ -83,7 +83,7 @@ test('daemon shutdown closes an open web session immediately, without waiting fo const stderrChunks: string[] = []; await teardownDaemonSessionForShutdown({ - session, + ref: sessionStore.lookup(session.name)!, sessionStore, stateDir: root, stderr: { write: (chunk) => stderrChunks.push(chunk) }, @@ -116,7 +116,7 @@ test('daemon shutdown reports a web close failure on stderr instead of losing it const stderrChunks: string[] = []; await teardownDaemonSessionForShutdown({ - session, + ref: sessionStore.lookup(session.name)!, sessionStore, stateDir: root, stderr: { write: (chunk) => stderrChunks.push(chunk) }, diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index fe73e03097..499cabeea1 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -35,7 +35,7 @@ import { finalizeDaemonSessionApplicationLifecycle } from '../application-lifecy import { runtimeHintValues } from '../session-runtime.ts'; import { closeDaemonServers } from './server-shutdown.ts'; import type { DaemonInvokeFn } from '../daemon-request.ts'; -import type { SessionState } from '../session-state.ts'; +import type { SessionRef, SessionState } from '../session-state.ts'; import { createDaemonIdleReap } from './daemon-idle-reap.ts'; import { createSessionIdleExpiry } from './daemon-session-idle-expiry.ts'; import { resolveSessionIdleExpiryMs } from '../session-idle-expiry.ts'; @@ -133,7 +133,7 @@ async function settleDaemonTeardownStep(params: { * silently swallowed. */ export async function teardownDaemonSessionForShutdown(params: { - session: SessionState; + ref: SessionRef; sessionStore: SessionStore; stateDir?: string; stderr: WritableOutput; @@ -142,7 +142,7 @@ export async function teardownDaemonSessionForShutdown(params: { afterSuccessfulTeardown?: (session: SessionState) => Promise; }): Promise { const { - session, + ref, sessionStore, stateDir, stderr, @@ -150,7 +150,7 @@ export async function teardownDaemonSessionForShutdown(params: { beforeDelete, afterSuccessfulTeardown, } = params; - const sessionName = sessionStore.resolveStoredSessionName(session); + const session = sessionStore.resolveCurrent(ref) ?? ref.session; const timeoutMs = resolveDaemonSessionTeardownTimeoutMs(session); // The ownership-fenced app-log side effect must settle while this process // still owns the daemon lock. It is intentionally outside the generic @@ -160,9 +160,9 @@ export async function teardownDaemonSessionForShutdown(params: { session, stderr, resource: 'app-log', - teardown: async () => await stopSessionAppLog({ session, sessionName, sessionStore }), + teardown: async () => await stopSessionAppLog({ ref, sessionStore }), }); - const sessionAfterAppLog = sessionStore.get(sessionName) ?? session; + const sessionAfterAppLog = sessionStore.resolveCurrent(ref) ?? session; const teardown = (async () => { const genericTeardownSucceeded = await settleDaemonTeardownStep({ session, @@ -171,8 +171,7 @@ export async function teardownDaemonSessionForShutdown(params: { teardown: async () => await teardownSessionResources({ appLog: 'already-settled', - session: sessionAfterAppLog, - sessionName, + ref, sessionStore, stateDir, platformCleanup: platformResourceCleanup, @@ -202,7 +201,7 @@ export async function teardownDaemonSessionForShutdown(params: { sessionStore.finalizeRepairTeardown(session); await beforeDelete?.(session); if (teardownSucceeded) await afterSuccessfulTeardown?.(session); - sessionStore.delete(sessionName); + sessionStore.retire(ref); } export type DaemonRuntimeOptions = { @@ -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)); }; @@ -503,10 +503,11 @@ export async function startDaemonRuntime( session: SessionState, sessionName: string, ): Promise => { + const ref = sessionStore.lookup(sessionName); + if (!ref) return; await teardownSessionResources({ appLog: 'run', - session, - sessionName, + ref, sessionStore, stateDir: baseDir, platformCleanup: platformResourceCleanup, 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 4dd4fe6c77..81eb0b82bd 100644 --- a/src/daemon/session-lifecycle/internal/session-close-lifecycle-teardown.ts +++ b/src/daemon/session-lifecycle/internal/session-close-lifecycle-teardown.ts @@ -134,21 +134,11 @@ async function stopBestEffortSessionResources( attemptCleanup: CleanupRunner, platformCleanup: PlatformResourceCleanup, ): Promise { - const { address: sessionName, session } = ref; - // Recording overlay finalization needs the Apple runner, so it runs first. - // `finishSessionScreenRecording` re-reads the stored session by address and - // returns when there is no recording; a second lookup here would only be a - // place to mis-address it. - await attemptCleanup('recording', () => - finishSessionScreenRecording({ session, sessionName, sessionStore }), - ); - await attemptCleanup('app_log', () => stopSessionAppLog({ session, sessionName, sessionStore })); - await attemptCleanup('audio_probe', () => - finishSessionAudioProbe({ session, sessionName, sessionStore }), - ); - await attemptCleanup('perf_capture', () => - stopSessionPerfCapture({ session, sessionName, sessionStore }), - ); + const session = sessionStore.resolveCurrent(ref) ?? ref.session; + await attemptCleanup('recording', () => finishSessionScreenRecording({ ref, sessionStore })); + await attemptCleanup('app_log', () => stopSessionAppLog({ ref, sessionStore })); + await attemptCleanup('audio_probe', () => finishSessionAudioProbe({ ref, sessionStore })); + await attemptCleanup('perf_capture', () => stopSessionPerfCapture({ ref, sessionStore })); await attemptCleanup('platform_snapshot_helper', () => stopSessionSnapshotHelper(session, platformCleanup), ); diff --git a/src/daemon/session-observability/internal/session-audio.ts b/src/daemon/session-observability/internal/session-audio.ts index 6d439e65b5..2d5292c1ea 100644 --- a/src/daemon/session-observability/internal/session-audio.ts +++ b/src/daemon/session-observability/internal/session-audio.ts @@ -20,7 +20,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 { SessionRef, SessionState } from '../../session-state.ts'; +import type { SessionRef } from '../../session-state.ts'; import { bindSessionAudioProbe } from '../../audio-probe-session-binding.ts'; import { type DaemonFailureResponse, errorResponse } from '@agent-device/kernel/contracts'; @@ -71,9 +71,9 @@ async function handleAudioCommandUnsafe(params: AudioParams): 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 +162,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 2e98bd25de..7d00c3cba8 100644 --- a/src/daemon/session-observability/internal/session-observability.ts +++ b/src/daemon/session-observability/internal/session-observability.ts @@ -323,10 +323,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); @@ -356,10 +354,8 @@ async function handleLogsStop(params: LogsHandlerParams): Promise, ): Promise { @@ -263,14 +263,11 @@ async function stopPerfCapture( } const completion = await finishLivePerfCapture({ intent: 'capture', - session, - sessionName: params.sessionName, - sessionStore: params.sessionStore, + binding: bindSessionPerfCapture(params.sessionStore, params.ref), }); if (request.area === 'cpu') { const profile = readProfileHandoff(completion); - const refreshed = params.sessionStore.get(params.sessionName) ?? session; - params.sessionStore.set(params.sessionName, { ...refreshed, lastPerfProfile: profile }); + params.sessionStore.update(params.ref, { lastPerfProfile: profile }); } return { ok: true, data: completion }; } diff --git a/src/daemon/session-teardown.ts b/src/daemon/session-teardown.ts index 2f051b2057..65f87445d6 100644 --- a/src/daemon/session-teardown.ts +++ b/src/daemon/session-teardown.ts @@ -1,9 +1,11 @@ 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 } from './audio-probe-session-binding.ts'; +import { bindSessionPerfCapture } from './perf-capture-session-binding.ts'; +import { bindSessionScreenRecording } from './screen-recording-session-binding.ts'; import type { SessionStore } from './session-store.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'; @@ -11,29 +13,19 @@ 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; const { forceCleanupSessionAppLog } = await import('./app-log-session-resource.ts'); - await forceCleanupSessionAppLog({ - session, - sessionName, - sessionStore, - resourcePath: appLogResourceStore.resolvePath(sessionStore.resolveSessionDir(sessionName)), - }); + await forceCleanupSessionAppLog(params); } 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 +43,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 +113,7 @@ export function reportSessionCleanupFailures(params: { } type SessionResourceTeardownRequest = { - session: SessionState; - sessionName: string; + ref: SessionRef; sessionStore: SessionStore; stateDir?: string; appLog: 'run' | 'already-settled'; @@ -131,7 +123,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 +138,7 @@ export async function teardownSessionResources( ? [ { step: 'app_log', - run: () => stopSessionAppLog({ session, sessionName, sessionStore }), + run: () => stopSessionAppLog({ ref, sessionStore }), }, ] : []; @@ -158,19 +152,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 +176,7 @@ export async function teardownSessionResources( step: 'web_browser', run: () => stopSessionWebBrowser({ - session, - sessionName, + ref, sessionStore, platformCleanup, }), @@ -204,31 +196,17 @@ export async function teardownSessionResources( } export async function finishSessionScreenRecording(params: { - session: SessionState; - sessionName: string; + ref: SessionRef; sessionStore: SessionStore; }): Promise { - const currentSession = params.sessionStore.get(params.sessionName) ?? params.session; - if (!currentSession.screenRecording) return; - await finishLiveScreenRecording({ - intent: 'disposal', - session: currentSession, - sessionName: params.sessionName, - sessionStore: params.sessionStore, - }); + const binding = bindSessionScreenRecording(params.sessionStore, params.ref); + if (binding.read()) await finishLiveScreenRecording({ binding, intent: 'disposal' }); } export async function finishSessionAudioProbe(params: { - session: SessionState; - sessionName: string; + ref: SessionRef; sessionStore: SessionStore; }): Promise { - const currentSession = params.sessionStore.get(params.sessionName) ?? params.session; - if (!currentSession.audioProbe) return; - await finishLiveAudioProbe({ - intent: 'disposal', - session: currentSession, - sessionName: params.sessionName, - sessionStore: params.sessionStore, - }); + const binding = bindSessionAudioProbe(params.sessionStore, params.ref); + if (binding.read()) await finishLiveAudioProbe({ binding, intent: 'disposal' }); } From d58cc91932967f7854d7de43f73418e7aa096525 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 06:50:44 +0200 Subject: [PATCH 22/44] chore(gates): retire whole-session capture projections --- .../capture-kit/src/durable-capture/index.ts | 2 -- .../layering/session-resource-ownership.test.ts | 16 +++++++++++++++- scripts/layering/session-resource-ownership.ts | 8 +------- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/capture-kit/src/durable-capture/index.ts b/packages/capture-kit/src/durable-capture/index.ts index dc4349e202..1d8f84b174 100644 --- a/packages/capture-kit/src/durable-capture/index.ts +++ b/packages/capture-kit/src/durable-capture/index.ts @@ -12,10 +12,8 @@ export type { AdoptStartedDurableCaptureParams, DurableCaptureFinishIntent, DurableCaptureRecordDefinition, - DurableCaptureResourceDefinition, DurableCaptureSessionResource, DurableCaptureSessionBinding, - DurableCaptureSessionStore, } from './definition.ts'; export type { FinishRecoveredDurableCaptureParams } from './finish-recovered.ts'; export type { diff --git a/scripts/layering/session-resource-ownership.test.ts b/scripts/layering/session-resource-ownership.test.ts index 4b2e7fc2b8..4fd9fd7045 100644 --- a/scripts/layering/session-resource-ownership.test.ts +++ b/scripts/layering/session-resource-ownership.test.ts @@ -24,7 +24,19 @@ test('session resources are constructed only by their durable domain owners', () ], [ 'src/daemon/app-log-session-resource.ts', - `sessionStore.set(name, { ...session, appLog: log, appLogFailure: undefined });`, + `sessionStore.update(ref, { appLog: log, appLogFailure: undefined });`, + ], + [ + 'src/daemon/audio-probe-session-binding.ts', + `sessionStore.update(ref, { audioProbe: audio });`, + ], + [ + 'src/daemon/perf-capture-session-binding.ts', + `sessionStore.update(ref, { perfCapture: perf });`, + ], + [ + 'src/daemon/screen-recording-session-binding.ts', + `sessionStore.update(ref, { screenRecording: recording });`, ], [ 'packages/capture-kit/src/capture-admission/audio-probe-session-resource.ts', @@ -41,6 +53,8 @@ test('session resources are constructed only by their durable domain owners', () '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 579dd3d637..24408f22b4 100644 --- a/scripts/layering/session-resource-ownership.ts +++ b/scripts/layering/session-resource-ownership.ts @@ -27,18 +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/audio-probe-session-binding.ts', - 'src/daemon/session-state.ts', - ]), + audioProbe: new Set(['src/daemon/audio-probe-session-binding.ts', 'src/daemon/session-state.ts']), screenRecording: new Set([ - 'packages/capture-kit/src/capture-admission/screen-recording-session-resource.ts', 'src/daemon/screen-recording-session-binding.ts', 'src/daemon/session-state.ts', ]), perfCapture: new Set([ - 'packages/capture-kit/src/capture-admission/perf-capture-session-resource.ts', 'src/daemon/perf-capture-session-binding.ts', 'src/daemon/session-state.ts', ]), From e0a818cd55e24d5982721683add83ecd494178fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 18:52:05 +0200 Subject: [PATCH 23/44] refactor: keep session capture bindings in one module Session teardown imported three per-capture binding modules plus their shared binding, which grew its eager closure past the merge-base. The audio, perf and screen-recording bindings now live beside bindSessionCapture. --- .../session-resource-ownership.test.ts | 14 +++------- .../layering/session-resource-ownership.ts | 11 +++----- .../perf-capture-session-resource.test.ts | 2 +- .../__tests__/session-capture-binding.test.ts | 2 +- src/daemon/audio-probe-session-binding.ts | 12 --------- src/daemon/handlers/record-runtime.ts | 6 ++--- src/daemon/perf-capture-session-binding.ts | 12 --------- .../screen-recording-session-binding.ts | 11 +------- src/daemon/session-capture-binding.ts | 27 +++++++++++++++++++ .../internal/session-audio.ts | 2 +- .../internal/session-perf-runtime.ts | 2 +- src/daemon/session-teardown.ts | 8 +++--- 12 files changed, 47 insertions(+), 62 deletions(-) delete mode 100644 src/daemon/audio-probe-session-binding.ts delete mode 100644 src/daemon/perf-capture-session-binding.ts diff --git a/scripts/layering/session-resource-ownership.test.ts b/scripts/layering/session-resource-ownership.test.ts index 4fd9fd7045..8a1453e6cb 100644 --- a/scripts/layering/session-resource-ownership.test.ts +++ b/scripts/layering/session-resource-ownership.test.ts @@ -27,16 +27,10 @@ test('session resources are constructed only by their durable domain owners', () `sessionStore.update(ref, { appLog: log, appLogFailure: undefined });`, ], [ - 'src/daemon/audio-probe-session-binding.ts', - `sessionStore.update(ref, { audioProbe: audio });`, - ], - [ - 'src/daemon/perf-capture-session-binding.ts', - `sessionStore.update(ref, { perfCapture: perf });`, - ], - [ - 'src/daemon/screen-recording-session-binding.ts', - `sessionStore.update(ref, { screenRecording: recording });`, + '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', diff --git a/scripts/layering/session-resource-ownership.ts b/scripts/layering/session-resource-ownership.ts index 24408f22b4..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,15 +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(['src/daemon/audio-probe-session-binding.ts', 'src/daemon/session-state.ts']), + audioProbe: new Set(['src/daemon/session-capture-binding.ts', 'src/daemon/session-state.ts']), screenRecording: new Set([ - 'src/daemon/screen-recording-session-binding.ts', - 'src/daemon/session-state.ts', - ]), - perfCapture: new Set([ - 'src/daemon/perf-capture-session-binding.ts', + '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/daemon/__tests__/perf-capture-session-resource.test.ts b/src/daemon/__tests__/perf-capture-session-resource.test.ts index ecd14eb954..6d47f62998 100644 --- a/src/daemon/__tests__/perf-capture-session-resource.test.ts +++ b/src/daemon/__tests__/perf-capture-session-resource.test.ts @@ -1,4 +1,4 @@ -import { bindSessionPerfCapture } from '../perf-capture-session-binding.ts'; +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'; diff --git a/src/daemon/__tests__/session-capture-binding.test.ts b/src/daemon/__tests__/session-capture-binding.test.ts index 41f5322d5b..423c192eed 100644 --- a/src/daemon/__tests__/session-capture-binding.test.ts +++ b/src/daemon/__tests__/session-capture-binding.test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'vitest'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { makeRecordingSession } from './session-teardown.fixtures.ts'; -import { bindSessionScreenRecording } from '../screen-recording-session-binding.ts'; +import { bindSessionScreenRecording } from '../session-capture-binding.ts'; test('clearing a capture refreshes a rebuilt record without losing its other changes', () => { const store = makeSessionStore(); diff --git a/src/daemon/audio-probe-session-binding.ts b/src/daemon/audio-probe-session-binding.ts deleted file mode 100644 index b4eecb43c7..0000000000 --- a/src/daemon/audio-probe-session-binding.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { bindSessionCapture } from './session-capture-binding.ts'; -import type { SessionRef } from './session-state.ts'; -import type { SessionStore } from './session-store.ts'; - -export function bindSessionAudioProbe(sessionStore: SessionStore, ref: SessionRef) { - return bindSessionCapture(sessionStore, ref, { - read: (session) => session.audioProbe, - write: (audioProbe) => { - sessionStore.update(ref, { audioProbe }); - }, - }); -} diff --git a/src/daemon/handlers/record-runtime.ts b/src/daemon/handlers/record-runtime.ts index ad8cfc5b16..a44632387f 100644 --- a/src/daemon/handlers/record-runtime.ts +++ b/src/daemon/handlers/record-runtime.ts @@ -31,10 +31,8 @@ import type { SessionStore } from '../session-store.ts'; import type { BindDeviceRuntime, BindExactDeviceRuntime } from '../request-runtime-binding.ts'; import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; import type { SessionRef, SessionState } from '../session-state.ts'; -import { - bindRecordOnlyScreenRecording, - bindSessionScreenRecording, -} from '../screen-recording-session-binding.ts'; +import { bindRecordOnlyScreenRecording } from '../screen-recording-session-binding.ts'; +import { bindSessionScreenRecording } from '../session-capture-binding.ts'; import { recordSessionAction } from '../session-action-recorder.ts'; import { missingAppSessionResponse, diff --git a/src/daemon/perf-capture-session-binding.ts b/src/daemon/perf-capture-session-binding.ts deleted file mode 100644 index 53463cd8ab..0000000000 --- a/src/daemon/perf-capture-session-binding.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { bindSessionCapture } from './session-capture-binding.ts'; -import type { SessionRef } from './session-state.ts'; -import type { SessionStore } from './session-store.ts'; - -export function bindSessionPerfCapture(sessionStore: SessionStore, ref: SessionRef) { - return bindSessionCapture(sessionStore, ref, { - read: (session) => session.perfCapture, - write: (perfCapture) => { - sessionStore.update(ref, { perfCapture }); - }, - }); -} diff --git a/src/daemon/screen-recording-session-binding.ts b/src/daemon/screen-recording-session-binding.ts index d032f819ba..b5d56813d0 100644 --- a/src/daemon/screen-recording-session-binding.ts +++ b/src/daemon/screen-recording-session-binding.ts @@ -1,16 +1,7 @@ -import { bindSessionCapture } from './session-capture-binding.ts'; +import { bindSessionScreenRecording } from './session-capture-binding.ts'; import type { SessionRef } from './session-state.ts'; import type { SessionStore } from './session-store.ts'; -export function bindSessionScreenRecording(sessionStore: SessionStore, ref: SessionRef) { - return bindSessionCapture(sessionStore, ref, { - read: (session) => session.screenRecording, - write: (screenRecording) => { - sessionStore.update(ref, { screenRecording }); - }, - }); -} - export function bindRecordOnlyScreenRecording( sessionStore: SessionStore, address: string, diff --git a/src/daemon/session-capture-binding.ts b/src/daemon/session-capture-binding.ts index c3f809cf37..ec7d7683fa 100644 --- a/src/daemon/session-capture-binding.ts +++ b/src/daemon/session-capture-binding.ts @@ -51,3 +51,30 @@ export function bindSessionCapture( }, }); } + +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-observability/internal/session-audio.ts b/src/daemon/session-observability/internal/session-audio.ts index 2d5292c1ea..104709368d 100644 --- a/src/daemon/session-observability/internal/session-audio.ts +++ b/src/daemon/session-observability/internal/session-audio.ts @@ -21,7 +21,7 @@ import type { import type { SessionStore } from '../../session-store.ts'; import type { DaemonRequest, DaemonResponse } from '../../daemon-request.ts'; import type { SessionRef } from '../../session-state.ts'; -import { bindSessionAudioProbe } from '../../audio-probe-session-binding.ts'; +import { bindSessionAudioProbe } from '../../session-capture-binding.ts'; import { type DaemonFailureResponse, errorResponse } from '@agent-device/kernel/contracts'; type AudioParams = { diff --git a/src/daemon/session-observability/internal/session-perf-runtime.ts b/src/daemon/session-observability/internal/session-perf-runtime.ts index 4c8b4142e0..3e3fa1d11c 100644 --- a/src/daemon/session-observability/internal/session-perf-runtime.ts +++ b/src/daemon/session-observability/internal/session-perf-runtime.ts @@ -32,7 +32,7 @@ import type { import type { SessionStore } from '../../session-store.ts'; import type { DaemonRequest, DaemonResponse } from '../../daemon-request.ts'; import type { SessionRef, SessionState } from '../../session-state.ts'; -import { bindSessionPerfCapture } from '../../perf-capture-session-binding.ts'; +import { bindSessionPerfCapture } from '../../session-capture-binding.ts'; import { recordSessionAction } from '../../session-action-recorder.ts'; import { admitRuntimePlan, diff --git a/src/daemon/session-teardown.ts b/src/daemon/session-teardown.ts index 65f87445d6..6b578e7328 100644 --- a/src/daemon/session-teardown.ts +++ b/src/daemon/session-teardown.ts @@ -2,9 +2,11 @@ import { AppError } from '@agent-device/kernel/errors'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { cleanupRetainedMaterializedPathsForSession } from './materialized-path-registry.ts'; import type { SessionRef, SessionState } from './session-state.ts'; -import { bindSessionAudioProbe } from './audio-probe-session-binding.ts'; -import { bindSessionPerfCapture } from './perf-capture-session-binding.ts'; -import { bindSessionScreenRecording } from './screen-recording-session-binding.ts'; +import { + bindSessionAudioProbe, + bindSessionPerfCapture, + bindSessionScreenRecording, +} from './session-capture-binding.ts'; import type { SessionStore } from './session-store.ts'; import { finishLiveAudioProbe } from '@agent-device/capture-kit/audio-probe-session-resource'; import { finishLivePerfCapture } from '@agent-device/capture-kit/perf-capture-session-resource'; From 41dfe9a8c156d8e9a9633be253a27bc2f6c5bfd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 07:24:50 +0200 Subject: [PATCH 24/44] fix: preserve daemon cleanup and capture lifetime proof --- .../__tests__/session-store.fixtures.ts | 28 +-- .../src/durable-capture/adoption.test.ts | 22 +++ .../durable-capture.fixtures.ts | 13 +- .../session-binding.fixtures.ts | 69 +++++-- .../src/durable-capture/transitions.test.ts | 64 ++++++ .../internal/owner-identity-liveness.test.ts | 2 + packages/host-kit/src/session-paths.test.ts | 3 +- .../registered-daemon-fixture.test.ts | 43 ++++ .../test-utils/registered-daemon-fixture.ts | 1 + .../daemon-client-startup-race.test.ts | 9 +- .../daemon-client-timeout-route.test.ts | 186 ++++++++++-------- src/daemon-client/daemon-client-timeout.ts | 5 +- .../app-log-session-resource.test.ts | 22 +++ .../perf-capture-session-resource.test.ts | 5 +- .../__tests__/session-capture-binding.test.ts | 53 ++++- .../__tests__/session-store-lifetime.test.ts | 5 + src/daemon/app-log-session-resource.ts | 2 +- src/daemon/handlers/record-runtime.ts | 3 +- src/daemon/session-capture-binding.ts | 9 +- .../__tests__/session-perf-runtime.test.ts | 36 ++++ .../internal/session-audio.ts | 1 + .../internal/session-observability.ts | 2 + .../internal/session-perf-runtime.ts | 4 +- src/daemon/session-store.ts | 5 + src/daemon/session-teardown.ts | 4 +- .../support/daemon-test-cleanup.test.ts | 104 ++++++++-- .../support/daemon-test-cleanup.ts | 61 ++++-- 27 files changed, 579 insertions(+), 182 deletions(-) create mode 100644 src/__tests__/test-utils/registered-daemon-fixture.test.ts 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 ab0ccd73e0..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 { mkdtempForTestSync } from '../../tmp-dir.fixtures.ts'; +import { makeCaptureFixtureStore } from '../../durable-capture/session-binding.fixtures.ts'; -export type CaptureAdmissionSessionStore = Readonly<{ - set(name: string, session: S): void; - resolveSessionDir(name: string): string; - 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/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/durable-capture.fixtures.ts b/packages/capture-kit/src/durable-capture/durable-capture.fixtures.ts index 363b83a45c..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,4 +1,4 @@ -import { makeCaptureSessionBinding } from './session-binding.fixtures.ts'; +import { makeCaptureSessionBinding, makeCaptureFixtureStore } from './session-binding.fixtures.ts'; import path from 'node:path'; import { vi, type Mock } from 'vitest'; import { @@ -78,15 +78,10 @@ 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 = { - get: (name: string) => sessions.get(name), - set: (name: string, next: TestCaptureSession) => void sessions.set(name, next), - resolveSessionDir, - }; + const sessionStore = makeCaptureFixtureStore(resolveSessionDir); + sessionStore.set(sessionName, session); const reportUndurableCleanup: Mock< (device: DeviceInfo, outcome: DurableCaptureCleanupOutcome) => void > = vi.fn(); @@ -96,7 +91,7 @@ export function makeDurableCaptureContext( read: (session) => session.capture, replace: (session, capture) => ({ ...session, capture }), }), - sessions, + sessions: sessionStore, sessionsDir, resolveSessionDir, session, diff --git a/packages/capture-kit/src/durable-capture/session-binding.fixtures.ts b/packages/capture-kit/src/durable-capture/session-binding.fixtures.ts index 8c37b7be49..e84740ee55 100644 --- a/packages/capture-kit/src/durable-capture/session-binding.fixtures.ts +++ b/packages/capture-kit/src/durable-capture/session-binding.fixtures.ts @@ -1,21 +1,51 @@ 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: Readonly<{ - get(name: string): S | undefined; - set(name: string, session: S): void; - resolveSessionDir(name: string): string; - }>, + 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.get(address); - if (!session) throw new AppError('COMMAND_FAILED', 'Test session retired'); + const session = store.resolveCurrent(ref); + if (session === undefined) throw new AppError('COMMAND_FAILED', 'Test session retired'); return session; }; const assertAdoptable = (): void => { @@ -25,23 +55,32 @@ export function makeCaptureSessionBinding { - const session = store.get(address); - return session === undefined ? undefined : slot.read(session); + const session = store.resolveCurrent(ref); + if (session !== undefined) retained = slot.read(session); + return retained; }, assertAdoptable, canPersist: () => { - const session = store.get(address); + const session = store.resolveCurrent(ref); return session !== undefined && slot.read(session) === undefined; }, adopt: (resource) => { assertAdoptable(); - store.set(address, slot.replace(requireSession(), resource)); + store.update(ref, (current) => slot.replace(current, resource)); + retained = resource; }, clear: (expected) => { - const current = store.get(address); - if (!current) return 'retired'; - if (slot.read(current)?.handle !== expected.handle) return 'resource-changed'; - store.set(address, slot.replace(current, undefined)); + 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 f691674c53..0ff01c1062 100644 --- a/packages/capture-kit/src/durable-capture/transitions.test.ts +++ b/packages/capture-kit/src/durable-capture/transitions.test.ts @@ -219,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/host-kit/src/internal/owner-identity-liveness.test.ts b/packages/host-kit/src/internal/owner-identity-liveness.test.ts index 772d29cac5..511da64644 100644 --- a/packages/host-kit/src/internal/owner-identity-liveness.test.ts +++ b/packages/host-kit/src/internal/owner-identity-liveness.test.ts @@ -66,4 +66,6 @@ test('a pid outside the native range is unknown without a liveness probe', () => '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/session-paths.test.ts b/packages/host-kit/src/session-paths.test.ts index be0c8d3116..0b443d3452 100644 --- a/packages/host-kit/src/session-paths.test.ts +++ b/packages/host-kit/src/session-paths.test.ts @@ -7,8 +7,7 @@ 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.startsWith(os.homedir()), true); - assert.equal(homePath.endsWith(path.join('flows', 'replay.ad')), true); + 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')); 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 index 6c99455a4e..9131617410 100644 --- a/src/__tests__/test-utils/registered-daemon-fixture.ts +++ b/src/__tests__/test-utils/registered-daemon-fixture.ts @@ -85,6 +85,7 @@ export async function waitForRegisteredDaemonFixture( 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)}`); 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 8788a8c887..60eb5a9366 100644 --- a/src/daemon-client/__tests__/daemon-client-startup-race.test.ts +++ b/src/daemon-client/__tests__/daemon-client-startup-race.test.ts @@ -212,23 +212,22 @@ test('a joined busy contender waits for a published winner to become ready witho fs.writeFileSync(deferred, 'wait'); const winner = spawnRegisteredDaemonFixture(paths, fields(http.port), { stdio: 'ignore' }); await awaitFile(path.join(paths.baseDir, 'registration-held')); - let joined = false; + let contenderExit: ExecDetachedExit | undefined; spawn.mockImplementation((_command, _args, options) => { const child = spawnRegisteredDaemonFixture(paths, fields(http.port), options); void child.exited.then((exit) => { - assert.equal(exit.exitCode, DAEMON_STARTUP_EXIT_CODES.busy); - joined = true; + contenderExit = exit; }); return child; }); pause.mockImplementation(async () => { - if (joined) fs.rmSync(deferred, { force: true }); + if (contenderExit) fs.rmSync(deferred, { force: true }); await actualRetry.sleep(10); }); const signal = vi.spyOn(process, 'kill'); try { assert.equal((await sendToDaemon(request(paths))).ok, true); - assert.equal(joined, 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); 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 c28d397d1d..88415d1a23 100644 --- a/src/daemon-client/__tests__/daemon-client-timeout-route.test.ts +++ b/src/daemon-client/__tests__/daemon-client-timeout-route.test.ts @@ -311,89 +311,115 @@ function timeoutDiagnostic(paths: DaemonPaths): Record { 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) { - test(`${transport} timeout waits for force retirement before reporting reset`, 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); - }); - 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; + 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, ); - 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'); - const retirement = error.details?.retirement as DaemonRetirementResult | undefined; - assert.ok(retirement?.status === 'retired'); - assert.equal(retirement.termination.mode, 'forced'); - assert.equal(fs.existsSync(paths.infoPath), false); - assert.equal(fs.existsSync(paths.lockPath), false); - assert.equal(fs.existsSync(paths.baseDir), true); - assert.equal(timeoutDiagnostic(paths).daemonPreservedAfterTimeout, false); - assert.equal(mockRunCmdSync.mock.calls.filter(([cmd]) => cmd === 'pkill').length, 3); - } finally { - kill.mockRestore(); - if (!exited) actualKill(child.pid, 'SIGKILL'); - await child.exited; - await outcome; - await finishRegisteredDaemonFixture(paths.baseDir); - await closeLoopbackServer(endpoint.server); - } - }); + 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) => { diff --git a/src/daemon-client/daemon-client-timeout.ts b/src/daemon-client/daemon-client-timeout.ts index f7132c4f87..12c87c0d93 100644 --- a/src/daemon-client/daemon-client-timeout.ts +++ b/src/daemon-client/daemon-client-timeout.ts @@ -73,7 +73,8 @@ export async function handleRequestTimeout( mode: 'force', }); } - const retained = retirement?.status === 'retained'; + 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 @@ -94,7 +95,7 @@ export async function handleRequestTimeout( daemonPidReset: retirement?.status === 'retired' ? info.pid : undefined, daemonPidForceKilled: resetDaemon ? daemonWasForceKilled(retirement) : undefined, daemonRetirement: retirement, - daemonPreservedAfterTimeout: retained || (!remote && !resetDaemon), + daemonPreservedAfterTimeout: preserved || (!remote && !resetDaemon), daemonBaseUrl: info.baseUrl, }, }); diff --git a/src/daemon/__tests__/app-log-session-resource.test.ts b/src/daemon/__tests__/app-log-session-resource.test.ts index 2a20ed6a0c..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(); diff --git a/src/daemon/__tests__/perf-capture-session-resource.test.ts b/src/daemon/__tests__/perf-capture-session-resource.test.ts index 6d47f62998..7441e03774 100644 --- a/src/daemon/__tests__/perf-capture-session-resource.test.ts +++ b/src/daemon/__tests__/perf-capture-session-resource.test.ts @@ -103,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, @@ -113,7 +114,7 @@ test('a perf stop whose pull failed re-collects the device-side trace the first }); await adoptStartedPerfCapture({ admissionLedger: createPerfCaptureAdmissionLedger(), - binding: bindSessionPerfCapture(sessionStore, sessionStore.lookup(sessionName)!), + binding, device, owner: localRuntimeOwner('android'), fence, @@ -127,7 +128,7 @@ test('a perf stop whose pull failed re-collects the device-side trace the first const stop = () => finishLivePerfCapture({ intent: 'capture', - binding: bindSessionPerfCapture(sessionStore, sessionStore.lookup(sessionName)!), + binding, }); await expect(stop()).rejects.toBe(pullFailure); diff --git a/src/daemon/__tests__/session-capture-binding.test.ts b/src/daemon/__tests__/session-capture-binding.test.ts index 423c192eed..1058b0f354 100644 --- a/src/daemon/__tests__/session-capture-binding.test.ts +++ b/src/daemon/__tests__/session-capture-binding.test.ts @@ -3,6 +3,43 @@ 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({ @@ -39,13 +76,15 @@ test('clearing an older handle or fence leaves a replacement capture intact', () store.update(ref, { screenRecording: replacement }); expect(binding.clear(active)).toBe('resource-changed'); expect(binding.read()).toBe(replacement); - const newerFence = { - ...active, - envelope: { ...active.envelope, fence: { token: 'next', generation: 2 } }, - }; - store.update(ref, { screenRecording: newerFence }); - expect(binding.clear(active)).toBe('resource-changed'); - expect(binding.read()).toBe(newerFence); + 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', () => { diff --git a/src/daemon/__tests__/session-store-lifetime.test.ts b/src/daemon/__tests__/session-store-lifetime.test.ts index d3b7ae7e65..d0e6a84cf0 100644 --- a/src/daemon/__tests__/session-store-lifetime.test.ts +++ b/src/daemon/__tests__/session-store-lifetime.test.ts @@ -30,6 +30,10 @@ test('refs capture records while resolving rebuilds from the same lifetime', () 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); }); @@ -56,6 +60,7 @@ test('address reuse with the same record still starts a different lifetime', () 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); diff --git a/src/daemon/app-log-session-resource.ts b/src/daemon/app-log-session-resource.ts index 89335eebcb..1099c5811e 100644 --- a/src/daemon/app-log-session-resource.ts +++ b/src/daemon/app-log-session-resource.ts @@ -137,7 +137,7 @@ export function clearSessionAppLogFailure(params: { }); } -function bindSessionAppLog(sessionStore: SessionStore, ref: SessionRef) { +export function bindSessionAppLog(sessionStore: SessionStore, ref: SessionRef) { return bindSessionCapture(sessionStore, ref, { read: (session) => session.appLog, write: (appLog) => { diff --git a/src/daemon/handlers/record-runtime.ts b/src/daemon/handlers/record-runtime.ts index a44632387f..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, @@ -32,7 +33,6 @@ import type { BindDeviceRuntime, BindExactDeviceRuntime } from '../request-runti import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; import type { SessionRef, SessionState } from '../session-state.ts'; import { bindRecordOnlyScreenRecording } from '../screen-recording-session-binding.ts'; -import { bindSessionScreenRecording } from '../session-capture-binding.ts'; import { recordSessionAction } from '../session-action-recorder.ts'; import { missingAppSessionResponse, @@ -153,6 +153,7 @@ async function startRecording( 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), ); diff --git a/src/daemon/session-capture-binding.ts b/src/daemon/session-capture-binding.ts index ec7d7683fa..97555f0b93 100644 --- a/src/daemon/session-capture-binding.ts +++ b/src/daemon/session-capture-binding.ts @@ -14,6 +14,7 @@ export function bindSessionCapture( write(resource: DurableCaptureSessionResource | undefined): void; }>, ): DurableCaptureSessionBinding { + let retained = slot.read(sessionStore.resolveCurrent(ref) ?? ref.session); const assertAdoptable = (): void => { sessionStore.assertAdmissionOpen(ref.address); if (slot.read(sessionStore.requireCurrent(ref))) { @@ -26,7 +27,11 @@ export function bindSessionCapture( return Object.freeze({ address: ref.address, sessionDir: sessionStore.resolveSessionDir(ref.address), - read: () => slot.read(sessionStore.resolveCurrent(ref) ?? ref.session), + read: () => { + const current = sessionStore.resolveCurrent(ref); + if (current) retained = slot.read(current); + return retained; + }, assertAdoptable, canPersist: () => { const current = sessionStore.resolveCurrent(ref); @@ -35,6 +40,7 @@ export function bindSessionCapture( adopt: (resource) => { assertAdoptable(); slot.write(resource); + retained = resource; }, clear: (expected) => { const current = sessionStore.resolveCurrent(ref); @@ -47,6 +53,7 @@ export function bindSessionCapture( ) return 'resource-changed'; slot.write(undefined); + retained = undefined; return 'cleared'; }, }); 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 104709368d..9a947fa1ce 100644 --- a/src/daemon/session-observability/internal/session-audio.ts +++ b/src/daemon/session-observability/internal/session-audio.ts @@ -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, diff --git a/src/daemon/session-observability/internal/session-observability.ts b/src/daemon/session-observability/internal/session-observability.ts index 7d00c3cba8..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, @@ -375,6 +376,7 @@ async function startSessionAppLog( resourcePath, device: session.device, }); + bindSessionAppLog(sessionStore, params.ref).assertAdoptable(); const result = await start({ sessionId: sessionName, appBundleId, diff --git a/src/daemon/session-observability/internal/session-perf-runtime.ts b/src/daemon/session-observability/internal/session-perf-runtime.ts index 3e3fa1d11c..f8761f6231 100644 --- a/src/daemon/session-observability/internal/session-perf-runtime.ts +++ b/src/daemon/session-observability/internal/session-perf-runtime.ts @@ -217,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, @@ -228,7 +230,7 @@ async function startPerfCapture( }); await adoptStartedPerfCapture({ admissionLedger: requirePerfCaptureAdmissionLedger(params), - binding: bindSessionPerfCapture(params.sessionStore, params.ref), + binding, device: session.device, owner: runtime.owner, fence, diff --git a/src/daemon/session-store.ts b/src/daemon/session-store.ts index 97e2d6dc4b..217b8b8dac 100644 --- a/src/daemon/session-store.ts +++ b/src/daemon/session-store.ts @@ -105,6 +105,11 @@ export class SessionStore { 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) { diff --git a/src/daemon/session-teardown.ts b/src/daemon/session-teardown.ts index 6b578e7328..e9e684fbef 100644 --- a/src/daemon/session-teardown.ts +++ b/src/daemon/session-teardown.ts @@ -18,8 +18,10 @@ export async function stopSessionAppLog(params: { ref: SessionRef; sessionStore: SessionStore; }): Promise { + const ref = params.sessionStore.refresh(params.ref); + if (!ref.session.appLog) return; const { forceCleanupSessionAppLog } = await import('./app-log-session-resource.ts'); - await forceCleanupSessionAppLog(params); + await forceCleanupSessionAppLog({ ...params, ref }); } export async function stopSessionPerfCapture(params: { diff --git a/test/integration/support/daemon-test-cleanup.test.ts b/test/integration/support/daemon-test-cleanup.test.ts index a83780c9d5..baba38e0a6 100644 --- a/test/integration/support/daemon-test-cleanup.test.ts +++ b/test/integration/support/daemon-test-cleanup.test.ts @@ -47,31 +47,99 @@ test.each(['string', 'out-of-range'])( }, ); -test('cleanup stops the registered replacement when its observation still names the exited original', async () => { - const paths = resolveDaemonPaths(mkdtempForTestSync('daemon-test-replaced-registration-')); +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); - const stopped = await stopDaemonProcess( - { pid: original.pid, startTime: observed.processStartTime ?? null }, - { mode: 'force', termTimeoutMs: 0, killTimeoutMs: 1_000 }, + assert.equal( + ( + await stopDaemonProcess( + { pid: original.pid, startTime: observed.processStartTime ?? null }, + { mode: 'force', termTimeoutMs: 0, killTimeoutMs: 1_000 }, + ) + ).status, + 'exited', ); - assert.equal(stopped.status, 'exited'); await original.exited; - const replacement = spawnRegisteredDaemonFixture(paths, fields, undefined); - await waitForRegisteredDaemonFixture(paths, replacement); - await cleanupDaemonTestState(paths.baseDir, observed); - assert.ok( - !isProcessAlive(replacement.pid) || - readHostProcessIdentityObservations([replacement.pid]) - .get(replacement.pid) - ?.state.startsWith('Z'), - 'the registered replacement must be dead before cleanup returns', + 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 replacement.exited; - assert.equal(isProcessAlive(replacement.pid), false); - assert.equal(fs.existsSync(paths.baseDir), false); + 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 848a90d450..aa5c64fe75 100644 --- a/test/integration/support/daemon-test-cleanup.ts +++ b/test/integration/support/daemon-test-cleanup.ts @@ -1,12 +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'; -import { ownerIdentityMatches, type OwnerIdentity } from '@agent-device/host-kit/process'; type TestDaemonIdentity = { pid: number; processStartTime?: string }; @@ -16,33 +21,55 @@ export async function cleanupDaemonTestState( observed: TestDaemonIdentity | null, ): Promise { try { - const identities = [ - observed ? { pid: observed.pid, startTime: observed.processStartTime ?? null } : null, - readIdentity(stateDir), - ].filter((identity): identity is OwnerIdentity => identity !== null); - if (identities.length === 0) throw new Error('No daemon lifetime was observed'); + 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') { - console.warn('Daemon test cleanup retained state:', stateDir, termination); - return; - } + 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(); } - const current = readIdentity(stateDir); - if (current && !identities.some((identity) => ownerIdentityMatches(identity, current))) - throw new Error('Daemon registration changed during test cleanup'); - fs.rmSync(stateDir, { recursive: true, force: true }); } catch (error) { console.warn('Daemon test cleanup retained state:', stateDir, normalizeError(error)); } } -function readIdentity(stateDir: string): OwnerIdentity | null { - const infoPath = path.join(stateDir, 'daemon.json'); +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'); From 054dd00542d1d822dd3a3ec5d699a5afe4880f88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 07:24:51 +0200 Subject: [PATCH 25/44] chore(gates): colocate durable capture field owners --- vitest.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/vitest.config.ts b/vitest.config.ts index bb9a0a0046..33237c9ecf 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -178,6 +178,7 @@ 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. From a360ff32a7e64322860f842bacd94abef7d1bcf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 07:38:59 +0200 Subject: [PATCH 26/44] fix: retire only the captured expired session lifetime --- src/daemon/__tests__/lease-lifecycle.test.ts | 4 +- ...quest-execution-scope-lease-expiry.test.ts | 132 ++++++++++++++++++ .../__tests__/request-execution-scope.test.ts | 41 ------ src/daemon/lease-lifecycle.ts | 25 ++-- src/daemon/request-execution-scope.ts | 19 ++- 5 files changed, 157 insertions(+), 64 deletions(-) create mode 100644 src/daemon/__tests__/request-execution-scope-lease-expiry.test.ts 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__/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/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 b19eaa3bcd..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,16 +409,16 @@ 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.lookup(sessionName); - if (!ref) return; + 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({ @@ -437,7 +436,7 @@ async function teardownExpiredSession(params: { bindDevice, session, stateDir: sessionStore.resolveDaemonStateDir(), - runtimeHints: runtimeHintValues(sessionStore.getRuntimeHints(sessionName)), + runtimeHints, }); } catch (cleanupError) { if (primaryError !== undefined) { From 7e96b83ddcbd98c41159585a748babd9d9a8b711 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 08:01:11 +0200 Subject: [PATCH 27/44] refactor: carry session lifetimes through close and script finalization --- src/__tests__/test-utils/store-factory.ts | 7 +++ .../filesystem-boundary-faults.test.ts | 3 +- .../session-replay-repair-acceptance.test.ts | 3 +- .../session-replay-repair-empty-tail.test.ts | 7 ++- ...-repair-transaction-close-ordering.test.ts | 5 +- .../session-replay-repair-transaction.test.ts | 7 ++- .../request-router-idle-expired.test.ts | 7 ++- .../request-router-repair-expired.test.ts | 16 +++-- .../request-save-script-transports.test.ts | 7 ++- .../__tests__/session-store-lifetime.test.ts | 52 +++++++++++++++- src/daemon/__tests__/session-store.test.ts | 17 +++--- .../handlers/session-script-publication.ts | 7 ++- src/daemon/server/daemon-runtime.ts | 14 ++--- ...mon-session-idle-expiry-scheduling.test.ts | 6 +- .../server/daemon-session-idle-expiry.test.ts | 52 +++++++++++++++- .../server/daemon-session-idle-expiry.ts | 42 +++++-------- .../session-close-lifecycle-runtime.test.ts | 32 ++++++++++ .../session-close-resource-cleanup.test.ts | 60 +++++++++++++++++++ .../__tests__/session-close-script.test.ts | 20 +++---- .../session-close-lifecycle-teardown.ts | 7 ++- .../internal/session-close-script.ts | 14 +++-- .../internal/session-close.ts | 20 ++++--- src/daemon/session-store.ts | 28 +++++---- 23 files changed, 321 insertions(+), 112 deletions(-) diff --git a/src/__tests__/test-utils/store-factory.ts b/src/__tests__/test-utils/store-factory.ts index 0c9d222497..cc5a128879 100644 --- a/src/__tests__/test-utils/store-factory.ts +++ b/src/__tests__/test-utils/store-factory.ts @@ -11,3 +11,10 @@ export function makeSessionStore(prefix = 'agent-device-test-'): SessionStore { export function makeStoredSessionRef(session: SessionState, address = session.name): SessionRef { return makeSessionStore().publish(address, session); } + +export function storeSessionForTest(store: SessionStore, session: SessionState): 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/__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__/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-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__/session-store-lifetime.test.ts b/src/daemon/__tests__/session-store-lifetime.test.ts index d0e6a84cf0..f834cc2f96 100644 --- a/src/daemon/__tests__/session-store-lifetime.test.ts +++ b/src/daemon/__tests__/session-store-lifetime.test.ts @@ -1,7 +1,13 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; import { test } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; -import { makeSession } from '../../__tests__/test-utils/session-factories.ts'; +import { + 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'; @@ -121,3 +127,47 @@ test('a ref from another store has no authority over the same address', () => { assert.equal(target.retire(foreign), false); assert.equal(target.requireCurrent(local), foreign.session); }); + +test('script writes use the latest matching record and refuse a retired lifetime', () => { + const store = makeSessionStore(); + const ref = store.publish(ADDRESS, makeSession('default')); + store.update(ref, { + scriptPublication: authoringPublication('armed'), + actions: [{ ts: 1, command: 'click', positionals: ['id="late-action"'], flags: {} }], + }); + const result = store.writeSessionLog(ref); + assert.equal(result.written, true); + if (result.written) assert.match(fs.readFileSync(result.path, 'utf8'), /late-action/); + store.retire(ref); + const successor = store.publish(ADDRESS, makeRepairCompleteSession('default')); + assert.throws(() => store.writeSessionLog(ref), ended); + store.finalizeRepairTeardown(ref); + const state = store.requireCurrent(successor).scriptPublication; + assert.equal(state?.kind, 'repair'); + if (state?.kind === 'repair') assert.equal(state.status, 'complete'); + assert.equal(successor.session.actions.length, 0); +}); + +test('repair tombstones follow the scoped address and cannot be written by a retired ref', () => { + const store = makeSessionStore(); + const ref = store.publish(ADDRESS, makeRepairArmedSession('default')); + store.update(ref, { + scriptPublication: { + kind: 'repair', + status: 'armed', + boundary: 0, + target: { kind: 'default', force: false }, + sourcePath: '/latest.ad', + }, + }); + store.writeRepairTombstone(ref); + assert.equal(store.readRepairTombstone(ADDRESS)?.owner, ADDRESS); + assert.equal(store.readRepairTombstone(ADDRESS)?.sourcePath, '/latest.ad'); + assert.equal(store.readRepairTombstone('default'), undefined); + store.retire(ref); + store.clearRepairTombstone(ADDRESS); + const successor = store.publish(ADDRESS, makeRepairArmedSession('default')); + store.writeRepairTombstone(ref); + assert.equal(store.readRepairTombstone(ADDRESS), undefined); + assert.equal(store.requireCurrent(successor), successor.session); +}); diff --git a/src/daemon/__tests__/session-store.test.ts b/src/daemon/__tests__/session-store.test.ts index 5eddff721d..0e00cfef09 100644 --- a/src/daemon/__tests__/session-store.test.ts +++ b/src/daemon/__tests__/session-store.test.ts @@ -1,3 +1,4 @@ +import { storeSessionForTest } from '../../__tests__/test-utils/store-factory.ts'; import { test } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -83,7 +84,7 @@ function recordClose(store: SessionStore, session: SessionState): void { } function writeScript({ root, store, session }: SessionStoreFixture): string { - store.writeSessionLog(session); + store.writeSessionLog(storeSessionForTest(store, session)); return readWrittenSessionScript(root); } @@ -134,7 +135,7 @@ test('saveScript flag enables .ad session log writing', () => { recordOpen(store, session); recordClose(store, session); - store.writeSessionLog(session); + store.writeSessionLog(storeSessionForTest(store, session)); assert.equal(listSessionScriptFiles(root).length, 1); }); @@ -397,7 +398,7 @@ test('saveScript path writes session log to custom location', async () => { recordOpen(store, session, { platform: 'ios', saveScript: customPath }); recordClose(store, session); - store.writeSessionLog(session); + store.writeSessionLog(storeSessionForTest(store, session)); await store.flushEvents(session.name); assert.equal(fs.existsSync(customPath), true); assert.equal(fs.existsSync(store.resolveEventLogPath(session.name)), true); @@ -723,7 +724,7 @@ test('writeRepairTombstone/readRepairTombstone round-trips owner + source path', sourcePath: '/flows/login.ad', }); - store.writeRepairTombstone(session); + store.writeRepairTombstone(storeSessionForTest(store, session)); const tombstone = store.readRepairTombstone('default'); assert.ok(tombstone); assert.equal(tombstone?.owner, 'default'); @@ -736,7 +737,7 @@ test('readRepairTombstone returns undefined once the tombstone has expired', () const store = new SessionStore(path.join(root, 'sessions')); const session = makeSession('default'); // TTL 0 => expiresAt <= now => already stale. - store.writeRepairTombstone(session, 0); + store.writeRepairTombstone(storeSessionForTest(store, session), 0); assert.equal(store.readRepairTombstone('default'), undefined); }); @@ -744,7 +745,7 @@ test('clearRepairTombstone removes a tombstone (a fresh replay --save-script cle const root = mkdtempForTestSync('agent-device-tombstone-clear-'); const store = new SessionStore(path.join(root, 'sessions')); const session = makeSession('default'); - store.writeRepairTombstone(session); + store.writeRepairTombstone(storeSessionForTest(store, session)); assert.ok(store.readRepairTombstone('default')); store.clearRepairTombstone('default'); @@ -778,7 +779,7 @@ test('BLOCKER 2: finalizeRepairTeardown of a COMPLETE transaction whose commit F session.actions = [{ ts: 1, command: 'open', positionals: ['Demo'], flags: {} }]; // Idle-reap/shutdown teardown (never routes through close's handler). - store.finalizeRepairTeardown(session); + store.finalizeRepairTeardown(storeSessionForTest(store, session)); // The prior complete artifact is untouched — teardown's failed commit // never clobbers it. @@ -819,7 +820,7 @@ test('BLOCKER 3: finalizeRepairTeardown auto-commit records a terminal close, pr // The source plan's terminal `close` was already skipped-while-armed // (Fix 3) — `session.actions` never gained one. Idle-reap/shutdown teardown // must synthesize it itself before auto-committing. - store.finalizeRepairTeardown(session); + store.finalizeRepairTeardown(storeSessionForTest(store, session)); assert.equal( session.scriptPublication?.kind === 'repair' ? session.scriptPublication.status : undefined, diff --git a/src/daemon/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/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index 499cabeea1..13aa4c346a 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -198,7 +198,7 @@ export async function teardownDaemonSessionForShutdown(params: { // ADR 0012 decision 6, R7 + commit semantics (C2/C5a): commit the healed // `.ad` iff the repair transaction completed, else leave a bounded // `REPAIR_SESSION_EXPIRED` tombstone for the reaped-before-finalize case. - sessionStore.finalizeRepairTeardown(session); + sessionStore.finalizeRepairTeardown(ref); await beforeDelete?.(session); if (teardownSucceeded) await afterSuccessfulTeardown?.(session); sessionStore.retire(ref); @@ -499,12 +499,10 @@ export async function startDaemonRuntime( // survive so the next pass can retry rather than leave a claim owned by a process that no longer // knows what it holds. So: resources, then the platform finalization that stops the execution host // and releases its lease, then the claim, cleared last, by the reaper. - const settleIdleExpiredSession = async ( - session: SessionState, - sessionName: string, - ): Promise => { - const ref = sessionStore.lookup(sessionName); - if (!ref) return; + const settleIdleExpiredSession = async (ref: SessionRef): Promise => { + const session = sessionStore.resolveCurrent(ref) ?? ref.session; + const sessionName = ref.address; + const runtimeHints = runtimeHintValues(sessionStore.getRuntimeHints(sessionName)); await teardownSessionResources({ appLog: 'run', ref, @@ -517,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. diff --git a/src/daemon/server/daemon-session-idle-expiry-scheduling.test.ts b/src/daemon/server/daemon-session-idle-expiry-scheduling.test.ts index 8aae5cd14b..4d1fa1051a 100644 --- a/src/daemon/server/daemon-session-idle-expiry-scheduling.test.ts +++ b/src/daemon/server/daemon-session-idle-expiry-scheduling.test.ts @@ -47,7 +47,7 @@ test('a session still inside its window survives a sweep that actually ran', asy sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), - settleSession: async (_session, sessionName) => { + settleSession: async ({ address: sessionName }) => { settledNames.push(sessionName); }, withinDiagnosticsScope: sweeps.withinDiagnosticsScope, @@ -300,7 +300,7 @@ test('a session that moved to another device is fenced by that device, not the s sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: locks, - settleSession: async (session) => { + settleSession: async ({ session }) => { settledDeviceIds.push(session.device.id); }, withinDiagnosticsScope: sweeps.withinDiagnosticsScope, @@ -479,7 +479,7 @@ test('a daemon beginning to leave does not start settling the next session', asy sessionStore, idleExpiryMs: WINDOW_MS, executionLocks: new Map(), - settleSession: async (_session, sessionName) => { + settleSession: async ({ address: sessionName }) => { settledNames.push(sessionName); if (sessionName === 'first') await firstHeld; }, diff --git a/src/daemon/server/daemon-session-idle-expiry.test.ts b/src/daemon/server/daemon-session-idle-expiry.test.ts index 402da7d219..b27d64b898 100644 --- a/src/daemon/server/daemon-session-idle-expiry.test.ts +++ b/src/daemon/server/daemon-session-idle-expiry.test.ts @@ -197,8 +197,8 @@ test('a session shutdown finalized mid-settle is not also reported as expired', executionLocks: new Map(), // Shutdown takes no execution lock: it tears the whole session set down directly, so it is the // one remover that can finish while a settle is still running. - settleSession: async (_session, sessionName) => { - sessionStore.delete(sessionName); + settleSession: async (ref) => { + sessionStore.retire(ref); }, withinDiagnosticsScope: sweeps.withinDiagnosticsScope, now: () => NOW, @@ -267,7 +267,7 @@ test('a settled session of another kind is never touched by the sweep', async () executionLocks: new Map(), // Succeeds rather than throwing: a rejected settle leaves every record standing for retry, which // would let this pass read as a correct refusal even if the sweep had torn both sessions down. - settleSession: async (_session, sessionName) => { + settleSession: async ({ address: sessionName }) => { settledNames.push(sessionName); }, now: () => NOW, @@ -376,3 +376,49 @@ test('a committed expiry finalizes the repair transaction it ends', async () => 'committed', ); }); + +test('an idle settle cannot finalize or retire a replacement at its scoped address', async () => { + const { sessionStore } = makeFixture('agent-device-idle-expiry-lifetime-'); + idleClaimedSession(sessionStore); + const old = sessionStore.lookup('default')!; + sessionStore.retire(old); + const address = 'cwd:idle:default'; + const ref = sessionStore.publish(address, old.session); + const sweeps = createSweepBarrier(); + let release!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + let entered = false; + const controller = createSessionIdleExpiry({ + sessionStore, + idleExpiryMs: WINDOW_MS, + executionLocks: new Map(), + settleSession: async () => { + entered = true; + await held; + }, + withinDiagnosticsScope: sweeps.withinDiagnosticsScope, + now: () => NOW, + }); + try { + controller.noteSessionsChanged(); + await waitFor(() => entered, 'the held idle settle'); + sessionStore.retire(ref); + const successor = sessionStore.publish(address, makeRepairCompleteSession('default')); + sessionStore.setRuntimeHints(address, { metroPort: 9090 }); + release(); + await sweeps.swept(); + assert.equal(sessionStore.requireCurrent(successor), successor.session); + assert.equal(successor.session.scriptPublication?.kind, 'repair'); + if (successor.session.scriptPublication?.kind === 'repair') { + assert.equal(successor.session.scriptPublication.status, 'complete'); + } + assert.equal(successor.session.actions.length, 0); + assert.equal(sessionStore.getRuntimeHints(address)?.metroPort, 9090); + assert.equal(sessionStore.readIdleExpiryTombstone(address), undefined); + } finally { + release(); + controller.cancel(); + } +}); diff --git a/src/daemon/server/daemon-session-idle-expiry.ts b/src/daemon/server/daemon-session-idle-expiry.ts index 0ffd0f33bf..158f63873b 100644 --- a/src/daemon/server/daemon-session-idle-expiry.ts +++ b/src/daemon/server/daemon-session-idle-expiry.ts @@ -11,11 +11,11 @@ import { sessionIdleDeadlineMs, type SessionIdleExpiryOutcome, } from '../session-idle-expiry.ts'; -import type { SessionState } from '../session-state.ts'; +import type { SessionRef, SessionState } from '../session-state.ts'; import type { SessionStore } from '../session-store.ts'; /** Settles one expired session's owned resources. Supplied by the runtime, which owns the seams. */ -export type IdleSessionSettler = (session: SessionState, sessionName: string) => Promise; +export type IdleSessionSettler = (ref: SessionRef) => Promise; /** The one outcome this reaper invents: the clear threw, so nothing about the claim is known. */ const CLAIM_CLEAR_FAILED = 'claim-clear-failed'; @@ -105,7 +105,7 @@ export function createSessionIdleExpiry(params: { // without taking any execution lock, so it is the one remover that can finalize a session out from // under a settle. A sweep already inside a settle cannot be recalled, and settles there because // stopping mid-teardown would strand a resource; what it must not do is write an idle-expiry - // marker over a shutdown's close, which `SessionStore.delete`'s answer detects. + // marker over a shutdown's close; retiring the captured lifetime detects that case. let closing = false; // Addresses with a settle in flight. A settle whose budget expired stopped being WAITED on, not // stopped: it keeps holding the session's execution lock until it actually finishes. Without this @@ -254,7 +254,7 @@ async function expireIdleSessions(params: IdleExpirySweepParams): Promise * that lands after the budget still counts. */ async function expireIdleSession( - params: IdleExpirySweepParams & { ref: { address: string; session: SessionState } }, + params: IdleExpirySweepParams & { ref: SessionRef }, ): Promise { const { address } = params.ref; // A release still in flight holds this session's locks, so queueing on them would have the sweep wait @@ -332,7 +332,7 @@ function budgetElapsedAfter(ms: number): Readonly<{ */ async function settleIdleSessionUnderLock( params: IdleExpirySweepParams & { - ref: { address: string; session: SessionState }; + ref: SessionRef; lockKeys: readonly RequestExecutionLockKey[]; }, ): Promise { @@ -349,7 +349,7 @@ async function settleIdleSessionUnderLock( if (params.closing()) return NOTHING_TO_RETRY; // Re-read under the locks rather than trusting the swept reference: the device may have moved, // and the lock keys were chosen from the pre-lock reading. - const settled = params.sessionStore.get(address); + const settled = params.sessionStore.resolveCurrent(params.ref); if (!settled) return NOTHING_TO_RETRY; if (settled.device.id !== session.device.id) return NOTHING_TO_RETRY; // Re-clocked here too: a command that admitted while this expiry was queuing has finished and @@ -357,8 +357,7 @@ async function settleIdleSessionUnderLock( const atMs = params.now(); if (!isSessionIdleExpired(settled, params.idleExpiryMs, atMs)) return NOTHING_TO_RETRY; const outcome = await settleExpiredSession({ - sessionName: address, - session: settled, + ref: params.sessionStore.refresh(params.ref), idleExpiryMs: params.idleExpiryMs, expiredAtMs: atMs, sessionStore: params.sessionStore, @@ -450,14 +449,14 @@ function rememberRetry( * successor owns the device now, and never blocks the expiry. */ async function settleExpiredSession(params: { - sessionName: string; - session: SessionState; + ref: SessionRef; idleExpiryMs: number; expiredAtMs: number; sessionStore: SessionStore; settleSession: IdleSessionSettler; }): Promise { - const { sessionName, session, idleExpiryMs, expiredAtMs } = params; + const { ref, idleExpiryMs, expiredAtMs } = params; + const { address: sessionName, session } = ref; const deviceKey = session.deviceClaim?.deviceKey; const identity = { session: sessionName, idleExpiryMs, ...(deviceKey ? { deviceKey } : {}) }; if (!(await releaseExpiredSessionResources(params, identity))) return undefined; @@ -477,21 +476,8 @@ async function settleExpiredSession(params: { // stamps COMMITTED onto the record, and a write onto an already-committed transaction is an // idempotent no-op. Finalizing a settle that is being held back would therefore mark a still-live // session's healed script as already published, and no later teardown would ever publish it. - params.sessionStore.finalizeRepairTeardown(session); - // `delete` reports whether a record was still here to remove. Every request-path remover — `close`, - // a replacing `open`, a lease-expiry teardown — removes a session from inside `runAdmitted`, which - // holds the same lock pair this settle holds, and a settle budget bounds only the sweep's WAIT and - // never these locks, so no request can reach this record while the release is running. Daemon - // shutdown is the one remover that takes no lock at all, and it is therefore the only way here. - // The session was ended by someone else, and that owner has already explained it; a marker written - // now would tell the next agent the session died of idleness when something else closed it. - // - // The finalize above already published this session's repair transaction, which is why "a failed - // settle changes nothing" is not this function's contract: publishing belongs to whoever ENDS the - // session, and shutdown ends it by finalizing the same live record, onto which this commit is an - // idempotent no-op. Deferring the finalize to below this guard would instead resolve the healed - // script's event-log directory by map identity, which a deleted record no longer answers correctly. - if (!params.sessionStore.delete(sessionName)) { + params.sessionStore.finalizeRepairTeardown(ref); + if (!params.sessionStore.retire(ref)) { emitDiagnostic({ level: 'info', phase: 'session_idle_expiry_superseded', @@ -528,11 +514,11 @@ async function settleExpiredSession(params: { * this step's diagnostic. */ async function releaseExpiredSessionResources( - params: { session: SessionState; sessionName: string; settleSession: IdleSessionSettler }, + params: { ref: SessionRef; settleSession: IdleSessionSettler }, identity: Readonly>, ): Promise { try { - await params.settleSession(params.session, params.sessionName); + await params.settleSession(params.ref); return true; } catch (error) { emitDiagnostic({ diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts index 7ca47b926c..05af3277fa 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts @@ -347,3 +347,35 @@ test('close expires the ref frame immediately before its admitted platform mutat expect(response?.ok).toBe(true); expect(mockDispatch).toHaveBeenCalledOnce(); }); + +test('app-only close expires the rebuilt record after admission, preserving the captured snapshot', async () => { + const sessionStore = makeSessionStore(); + const address = 'cwd:close:default'; + const session = makeSession('default', { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, + }); + session.appBundleId = 'com.example.app'; + activateCompleteRefFrame(session); + const ref = sessionStore.publish(address, session); + mockInspectDeviceRuntimeFacts.mockImplementationOnce(async (candidate) => { + sessionStore.update(ref, { appName: 'Updated during admission' }); + return lifecycleRuntimeFacts(candidate); + }); + + const response = await close({ + sessionName: address, + sessionStore, + positionals: ['com.example.app'], + internal: { closeAppOnly: true }, + }); + + expect(response?.ok).toBe(true); + expect(refFrameState(sessionStore.requireCurrent(ref))).toBe('expired'); + expect(sessionStore.requireCurrent(ref).appName).toBe('Updated during admission'); + expect(refFrameState(ref.session)).toBe('active'); + expect(mockDispatch).toHaveBeenCalledOnce(); +}); diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-close-resource-cleanup.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-close-resource-cleanup.test.ts index e77972a7f3..41f5193bdf 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-close-resource-cleanup.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-close-resource-cleanup.test.ts @@ -259,3 +259,63 @@ test('close preserves the session and lease when provider release fails so it ca expect(sessionStore.get(sessionName)).toBeUndefined(); expect(leaseRegistry.listActiveLeases()).toHaveLength(0); }); + +test('close cannot retire a replacement session while its provider release waits', async () => { + const sessionStore = makeSessionStore(); + const leaseRegistry = new LeaseRegistry(); + const address = 'cwd:provider-close:default'; + const lease = leaseRegistry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'browserstack', + deviceKey: 'ios:bs-device', + clientId: 'client-a', + }); + const ref = sessionStore.publish(address, { + ...makeSession('default', WEB_DESKTOP_DEVICE), + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + leaseBackend: lease.backend, + leaseProvider: lease.leaseProvider, + deviceKey: lease.deviceKey, + clientId: lease.clientId, + expiresAt: lease.expiresAt, + }, + }); + let release!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + const providerRelease = vi.fn(async () => { + await held; + return { releasedBy: 'provider' }; + }); + const closing = handleSessionCommands({ + req: { token: 't', session: address, command: 'close', positionals: [], flags: {} }, + sessionName: address, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + sessionStore, + leaseRegistry, + leaseLifecycleProvider: { release: providerRelease }, + invoke: noopInvoke, + }); + try { + await vi.waitFor(() => expect(providerRelease).toHaveBeenCalledOnce()); + sessionStore.retire(ref); + const successor = sessionStore.publish(address, makeSession('default', WEB_DESKTOP_DEVICE)); + sessionStore.setRuntimeHints(address, { metroPort: 9090 }); + release(); + expect(await closing).toMatchObject({ + ok: true, + data: { provider: { releasedBy: 'provider' } }, + }); + expect(sessionStore.requireCurrent(successor)).toBe(successor.session); + expect(sessionStore.getRuntimeHints(address)).toEqual({ metroPort: 9090 }); + expect(leaseRegistry.listActiveLeases()).toHaveLength(0); + } finally { + release(); + await closing.catch(() => {}); + } +}); diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-close-script.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-close-script.test.ts index 34acf3f61e..ad90ee5d3b 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-close-script.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-close-script.test.ts @@ -34,7 +34,7 @@ function setup(name: string, session = makeIosSession(name, { appBundleId: 'com. roots.push(root); const sessionsDir = path.join(root, 'sessions'); const sessionStore = new SessionStore(sessionsDir); - sessionStore.set(name, session); + const ref = sessionStore.publish(name, session); const req: DaemonRequest = { token: 'token', session: name, @@ -42,18 +42,18 @@ function setup(name: string, session = makeIosSession(name, { appBundleId: 'com. positionals: [], flags: {}, }; - return { req, session, sessionStore, sessionsDir }; + return { req, ref, session, sessionStore, sessionsDir }; } test('failed repair publication removes only its synthetic close before retry', () => { - const { req, session, sessionStore } = setup( + const { req, ref, session, sessionStore } = setup( 'repair', makeRepairCompleteSession('repair', { appBundleId: 'com.example.app' }), ); const failure = new AppError('COMMAND_FAILED', 'publish failed'); vi.spyOn(sessionStore, 'writeSessionLog').mockReturnValue({ written: false, error: failure }); - expect(commitRepairScriptBeforeClose(sessionStore, session, req)).toEqual({ + expect(commitRepairScriptBeforeClose(sessionStore, ref, req)).toEqual({ kind: 'failed', error: failure, }); @@ -92,7 +92,7 @@ test('repair close failure keeps normalized metadata and is explicitly retriable }); test('ordinary publication failure retains its close action after making the error non-retriable', () => { - const { req, session, sessionStore } = setup('ordinary'); + const { req, ref, session, sessionStore } = setup('ordinary'); const failure = new AppError('COMMAND_FAILED', 'target exists', { reason: 'target-exists', hint: 'Retry close.', @@ -103,7 +103,7 @@ test('ordinary publication failure retains its close action after making the err const result = finalizeOrdinaryCloseScript({ req: { ...req, flags: { saveScript: true } }, - session, + ref, sessionStore, platformCloseError: undefined, }); @@ -126,7 +126,7 @@ test('ordinary publication failure retains its close action after making the err // that promise: the plain-close teardown path must write nothing. test('#1533: bare close on an aborted authoring session writes no script', () => { - const { req, session, sessionStore, sessionsDir } = setup( + const { req, ref, sessionStore, sessionsDir } = setup( 'aborted', makeIosSession('aborted', { appBundleId: 'com.example.app', @@ -139,7 +139,7 @@ test('#1533: bare close on an aborted authoring session writes no script', () => expect( finalizeOrdinaryCloseScript({ req, - session, + ref, sessionStore, platformCloseError: undefined, }), @@ -150,7 +150,7 @@ test('#1533: bare close on an aborted authoring session writes no script', () => }); test('#1533: an ordinary armed authoring session still publishes on bare close', () => { - const { req, session, sessionStore, sessionsDir } = setup( + const { req, ref, sessionStore, sessionsDir } = setup( 'armed', makeIosSession('armed', { appBundleId: 'com.example.app', @@ -162,7 +162,7 @@ test('#1533: an ordinary armed authoring session still publishes on bare close', expect( finalizeOrdinaryCloseScript({ req, - session, + ref, sessionStore, platformCloseError: undefined, }), diff --git a/src/daemon/session-lifecycle/internal/session-close-lifecycle-teardown.ts b/src/daemon/session-lifecycle/internal/session-close-lifecycle-teardown.ts index 81eb0b82bd..202996c16d 100644 --- a/src/daemon/session-lifecycle/internal/session-close-lifecycle-teardown.ts +++ b/src/daemon/session-lifecycle/internal/session-close-lifecycle-teardown.ts @@ -47,7 +47,7 @@ export async function runSessionCloseTeardown(params: { dispatchTargetedPlatformClose: PlatformCloseDispatcher; finalizeOrdinaryCloseScript(input: { req: DaemonRequest; - session: SessionState; + ref: SessionRef; sessionStore: SessionStore; platformCloseError: unknown; }): Error | undefined; @@ -66,7 +66,7 @@ export async function runSessionCloseTeardown(params: { finalizeOrdinaryCloseScript, } = params; const { address: sessionName } = ref; - const session = sessionStore.requireCurrent(ref); + let session = sessionStore.requireCurrent(ref); const attemptCleanup = async ( step: string, run: () => Promise, @@ -91,6 +91,7 @@ export async function runSessionCloseTeardown(params: { attemptCleanup, params.platformResourceCleanup, ); + session = sessionStore.requireCurrent(ref); const platformCloseError = repairArmed ? undefined : await dispatchTargetedPlatformClose({ req, session, logPath, lifecycle }); @@ -119,7 +120,7 @@ export async function runSessionCloseTeardown(params: { ); const saveScriptError = repairArmed ? undefined - : finalizeOrdinaryCloseScript({ req, session, sessionStore, platformCloseError }); + : finalizeOrdinaryCloseScript({ req, ref, sessionStore, platformCloseError }); await attemptCleanup('materialized_paths', () => cleanupRetainedMaterializedPathsForSession(sessionName), ); diff --git a/src/daemon/session-lifecycle/internal/session-close-script.ts b/src/daemon/session-lifecycle/internal/session-close-script.ts index 47fbfc88f2..ec64319b86 100644 --- a/src/daemon/session-lifecycle/internal/session-close-script.ts +++ b/src/daemon/session-lifecycle/internal/session-close-script.ts @@ -3,7 +3,7 @@ import { successText } from '@agent-device/kernel/success-text'; import type { CommandFlags } from '@agent-device/contracts/command'; import type { SessionStore } from '../../session-store.ts'; import type { DaemonRequest, DaemonResponse } from '../../daemon-request.ts'; -import type { SessionState } from '../../session-state.ts'; +import type { SessionRef, SessionState } from '../../session-state.ts'; import { NO_SCRIPT_PUBLICATION, scriptTargetPath } from '../../session-script-publication-state.ts'; import { effectiveWriteForce, @@ -33,15 +33,16 @@ function recordCloseAction( export function commitRepairScriptBeforeClose( sessionStore: SessionStore, - session: SessionState, + ref: SessionRef, req: DaemonRequest, ): RepairCloseCommit { + const session = sessionStore.requireCurrent(ref); if (!isRepairArmedSession(session)) return { kind: 'not-armed' }; const actionsBeforeClose = session.actions.length; recordCloseAction(sessionStore, session, req); const alreadyPublished = isSessionScriptPublished(session); - const result = sessionStore.writeSessionLog(session, { + const result = sessionStore.writeSessionLog(ref, { force: effectiveWriteForce(session, req.flags?.force), }); if (result.written) return { kind: 'committed', path: result.path }; @@ -77,11 +78,12 @@ export function buildRetriableRepairCloseFailureResponse( export function finalizeOrdinaryCloseScript(params: { req: DaemonRequest; - session: SessionState; + ref: SessionRef; sessionStore: SessionStore; platformCloseError: unknown; }): AppError | undefined { - const { req, session, sessionStore, platformCloseError } = params; + const { req, ref, sessionStore, platformCloseError } = params; + const session = sessionStore.requireCurrent(ref); if (!platformCloseError) { recordCloseAction(sessionStore, session, req); } @@ -90,7 +92,7 @@ export function finalizeOrdinaryCloseScript(params: { // session default rather than the request's explicit path — the lifecycle armed by `open` is // what authorizes the write, and it is untouched by that failure. try { - const result = sessionStore.writeSessionLog(session, { + const result = sessionStore.writeSessionLog(ref, { force: effectiveWriteForce(session, req.flags?.force), }); if (result.written) markCloseGeneratedPublicationDone(session, result.path); diff --git a/src/daemon/session-lifecycle/internal/session-close.ts b/src/daemon/session-lifecycle/internal/session-close.ts index 9d2f95dd9b..7654c1bc0e 100644 --- a/src/daemon/session-lifecycle/internal/session-close.ts +++ b/src/daemon/session-lifecycle/internal/session-close.ts @@ -68,12 +68,13 @@ const shouldDispatchPlatformClose = (req: DaemonRequest, session: SessionState): async function prepareRepairClose(params: { req: DaemonRequest; - session: SessionState; + ref: SessionRef; logPath: string; sessionStore: SessionStore; lifecycle: CloseRuntime | CloseRuntimeWithRuntimeHintClear; }): Promise { - const { req, session, logPath, sessionStore, lifecycle } = params; + const { req, ref, logPath, sessionStore, lifecycle } = params; + const session = sessionStore.requireCurrent(ref); const repairArmed = isRepairArmedSession(session); const closeReceipt = JSON.stringify(req.positionals ?? []); if (repairArmed && !hasRepairPlatformCloseReceipt(session, closeReceipt)) { @@ -93,9 +94,9 @@ async function prepareRepairClose(params: { ), }; } - recordRepairPlatformClose(session, closeReceipt); + recordRepairPlatformClose(sessionStore.requireCurrent(ref), closeReceipt); } - const repairCommit = commitRepairScriptBeforeClose(sessionStore, session, req); + const repairCommit = commitRepairScriptBeforeClose(sessionStore, ref, req); if (repairCommit.kind === 'failed') { // Publication failure retains target, force, and the close receipt; the same-identity retry // skips close dispatch above. @@ -202,7 +203,7 @@ export async function handleSessionCloseCommands( bindDevice: params.bindDevice, }); } - const session = ref.session; + let session = sessionStore.requireCurrent(ref); assertTerminalRecordingCloseAllowed(req, session); const app = req.positionals?.[0]; if (req.internal?.closeAppOnly === true && !app) { @@ -225,6 +226,7 @@ export async function handleSessionCloseCommands( bindDevice: params.bindDevice, }); if (admission.type === 'response') return admission.response; + session = sessionStore.requireCurrent(ref); // Teardown can restore durable IME state, terminate an app, or shut down a target. All are // mutating leaves, so invalidate the frame before the first teardown phase, not after dispatch. expireRefFrame(session); @@ -239,7 +241,7 @@ export async function handleSessionCloseCommands( } const repair = await prepareRepairClose({ req, - session, + ref, logPath, sessionStore, lifecycle: admission.runtime, @@ -301,7 +303,7 @@ async function runCloseTeardownAndRelease(params: { lifecycle, clearRuntimeHints, } = params; - const { address: sessionName, session } = ref; + const { address: sessionName } = ref; const cleanupFailures: SessionCleanupFailure[] = []; const { platformCloseError, saveScriptError, shutdownResult } = await runSessionCloseTeardown({ req, @@ -316,12 +318,14 @@ async function runCloseTeardownAndRelease(params: { finalizeOrdinaryCloseScript, platformResourceCleanup: params.platformResourceCleanup, }); + let session = sessionStore.requireCurrent(ref); const leaseRelease = await releaseProviderLeaseForClose({ session, leaseRegistry, leaseLifecycleProvider, }); if (leaseRelease.response) return { kind: 'response', response: leaseRelease.response }; + session = sessionStore.resolveCurrent(ref) ?? session; const cleanupAggregate = closeCleanupError(sessionName, cleanupFailures); const deviceClaimBlockingError = platformCloseError ?? cleanupAggregate; if (deviceClaimBlockingError) { @@ -338,7 +342,7 @@ async function runCloseTeardownAndRelease(params: { } else { await clearDeviceClaim(session.deviceClaim); } - sessionStore.delete(sessionName); + sessionStore.retire(ref); if (deviceClaimBlockingError) throw deviceClaimBlockingError; if (saveScriptError) throw saveScriptError; return { kind: 'closed', providerData: leaseRelease.providerData, shutdownResult }; diff --git a/src/daemon/session-store.ts b/src/daemon/session-store.ts index 217b8b8dac..00baf86df3 100644 --- a/src/daemon/session-store.ts +++ b/src/daemon/session-store.ts @@ -231,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({ @@ -273,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); } } } @@ -324,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) @@ -340,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), }, }); From 9a5d972221f2d4c6bdea16f54487e5c75fd456bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 08:42:58 +0200 Subject: [PATCH 28/44] refactor: bind snapshot publication to session lifetimes --- .../__tests__/android-owner-seam.test.ts | 6 +- src/daemon/__tests__/generic-settle.test.ts | 3 +- src/daemon/__tests__/runtime-session.test.ts | 38 ++++- .../selector-capture-runtime.test.ts | 44 ++++++ src/daemon/__tests__/session-snapshot.test.ts | 8 +- .../snapshot-command-runtime.test.ts | 57 ++++++++ src/daemon/handlers/react-native.ts | 13 +- src/daemon/interaction/index.ts | 17 ++- .../interaction-gesture-drag.test.ts | 6 +- .../interaction-ios-tap-outcome.test.ts | 4 +- .../interaction-snapshot-scope.test.ts | 56 +++++++- .../internal/find-target-capture.ts | 4 +- src/daemon/interaction/internal/find.ts | 10 +- .../internal/interaction-gesture.ts | 3 + .../internal/interaction-ios-tap-outcome.ts | 10 +- .../internal/interaction-runtime.ts | 18 +-- .../internal/interaction-session.ts | 12 ++ .../interaction-touch-android-freshness.ts | 2 +- .../internal/interaction-touch-direct-ios.ts | 2 +- .../internal/interaction-touch-fill.ts | 2 + .../internal/interaction-touch-press.ts | 2 + .../interaction-touch-reference-frame.ts | 13 +- .../internal/interaction-touch-response.ts | 2 +- .../internal/interaction-touch-runtime.ts | 8 +- .../interaction/internal/interaction.ts | 10 +- src/daemon/interaction/internal/types.ts | 5 +- src/daemon/runtime-session.ts | 37 ++++- src/daemon/screenshot-runtime.ts | 9 +- src/daemon/selector-capture-runtime.ts | 18 +-- src/daemon/selector-runtime-backend.test.ts | 2 + src/daemon/selector-runtime-backend.ts | 34 +++-- src/daemon/selector-runtime.ts | 6 +- .../daemon-runtime-snapshot-shutdown.test.ts | 125 +++++++++++++++++ src/daemon/session-snapshot.ts | 32 ++--- src/daemon/snapshot-command-runtime.ts | 132 +++++++++--------- src/daemon/snapshot-runtime-binding.ts | 6 +- src/daemon/snapshot-runtime.ts | 8 +- src/daemon/snapshot-session.ts | 18 +-- src/daemon/wait-runtime.ts | 11 +- 39 files changed, 575 insertions(+), 218 deletions(-) create mode 100644 src/daemon/interaction/internal/interaction-session.ts create mode 100644 src/daemon/server/daemon-runtime-snapshot-shutdown.test.ts 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__/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__/runtime-session.test.ts b/src/daemon/__tests__/runtime-session.test.ts index 60426909d6..8c70b3e82f 100644 --- a/src/daemon/__tests__/runtime-session.test.ts +++ b/src/daemon/__tests__/runtime-session.test.ts @@ -1,5 +1,6 @@ import { test, expect } from 'vitest'; import fs from 'node:fs'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { makeIosSession } from '../../__tests__/test-utils/session-factories.ts'; import { flushDiagnosticsToSessionFile, @@ -14,9 +15,12 @@ test('createDaemonRuntimeSessionStore hides non-matching sessions and scopes wri const tempHome = mkdtempForTestSync('agent-device-runtime-session-home-'); const session = makeIosSession('qa-ios'); const writes: CommandSessionRecord[] = []; + const sessionStore = makeSessionStore(); + const ref = sessionStore.publish(session.name, session); const store = createDaemonRuntimeSessionStore({ sessionName: 'qa-ios', - getSession: () => session, + sessionStore, + ref, recordOptions: { includeSnapshot: true }, setRecord: (record) => { writes.push(record); @@ -57,3 +61,35 @@ test('createDaemonRuntimeSessionStore hides non-matching sessions and scopes wri fs.rmSync(tempHome, { recursive: true, force: true }); } }); + +test('runtime projections follow rebuilds and reject writes after their lifetime ends', async () => { + const sessionStore = makeSessionStore(); + const address = 'cwd:runtime:default'; + const ref = sessionStore.publish(address, makeIosSession('default')); + const writes: string[] = []; + const runtime = createDaemonRuntimeSessionStore({ + sessionName: address, + sessionStore, + ref, + setRecord: (_record, current) => { + writes.push(current!.appName!); + }, + }); + sessionStore.update(ref, { appName: 'Rebuilt' }); + expect(await runtime.get(address)).toMatchObject({ appName: 'Rebuilt' }); + await runtime.set({ name: address }); + expect(writes).toEqual(['Rebuilt']); + sessionStore.retire(ref); + const successor = sessionStore.publish( + address, + makeIosSession('default', { appName: 'Successor' }), + ); + expect(await runtime.get(address)).toBeUndefined(); + expect(() => runtime.set({ name: address })).toThrowError( + expect.objectContaining({ + details: expect.objectContaining({ reason: 'session_lifetime_ended' }), + }), + ); + expect(writes).toEqual(['Rebuilt']); + expect(sessionStore.requireCurrent(successor)).toBe(successor.session); +}); diff --git a/src/daemon/__tests__/selector-capture-runtime.test.ts b/src/daemon/__tests__/selector-capture-runtime.test.ts index 9384dc7553..d90131a822 100644 --- a/src/daemon/__tests__/selector-capture-runtime.test.ts +++ b/src/daemon/__tests__/selector-capture-runtime.test.ts @@ -52,6 +52,7 @@ test('selector capture cache is keyed by scoped presentation options', async () })); const runtime = createSelectorCaptureRuntime({ + ref: sessionStore.lookup(sessionName), device: session.device, session, sessionStore, @@ -225,6 +226,7 @@ function proofRuntime(params: { const consumedSnapshot: { state?: SnapshotState } = {}; const captureProof: RequestCaptureProof = {}; const runtime = createSelectorCaptureRuntime({ + ref: sessionStore.lookup(params.sessionName), device: session.device, session, sessionStore, @@ -318,6 +320,7 @@ function makeCaptureRuntime(sessionName: string) { const session = makeIosSession(sessionName); sessionStore.set(sessionName, session); const runtime = createSelectorCaptureRuntime({ + ref: sessionStore.lookup(sessionName), device: session.device, session, sessionStore, @@ -333,3 +336,44 @@ function makeCaptureRuntime(sessionName: string) { }); return { runtime, sessionName, sessionStore }; } + +test('a held selector capture updates the matching rebuilt record without restoring its old fields', async () => { + const sessionStore = makeSessionStore(); + const address = 'cwd:selector-capture:default'; + const ref = sessionStore.publish(address, makeIosSession('default')); + let release!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + boundCapture.mockImplementationOnce(async () => { + await held; + return { + backend: 'xctest', + producer: 'apple-runner', + nodes: [{ index: 0, type: 'Button', label: 'Late capture' }], + }; + }); + const runtime = createSelectorCaptureRuntime({ + ref, + device: ref.session.device, + session: ref.session, + sessionStore, + sessionName: address, + capture: boundCapture, + req: { token: 't', session: address, command: 'get', positionals: [], flags: {} }, + }); + const running = runtime.capture({ flags: {} }); + try { + await vi.waitFor(() => expect(boundCapture).toHaveBeenCalledOnce()); + sessionStore.update(ref, { appName: 'Intervening rebuild' }); + release(); + await running; + expect(sessionStore.requireCurrent(ref).appName).toBe('Intervening rebuild'); + expect(sessionStore.requireCurrent(ref).snapshot?.nodes[0]?.label).toBe('Late capture'); + expect(ref.session.snapshot).toBeUndefined(); + expect(sessionStore.get('default')).toBeUndefined(); + } finally { + release(); + await running.catch(() => {}); + } +}); diff --git a/src/daemon/__tests__/session-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__/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/handlers/react-native.ts b/src/daemon/handlers/react-native.ts index fadabcab01..a7294d39dc 100644 --- a/src/daemon/handlers/react-native.ts +++ b/src/daemon/handlers/react-native.ts @@ -1,3 +1,4 @@ +import { bindInteractionSession } from '../interaction/index.ts'; import { createBoundTouchExecutor, resolveBoundTouchRuntime, @@ -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/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-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..d7825b5d2f 100644 --- a/src/daemon/interaction/internal/interaction-touch-fill.ts +++ b/src/daemon/interaction/internal/interaction-touch-fill.ts @@ -1,3 +1,4 @@ +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'; @@ -48,6 +49,7 @@ 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; 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..5aa80eaa47 100644 --- a/src/daemon/interaction/internal/interaction-touch-response.ts +++ b/src/daemon/interaction/internal/interaction-touch-response.ts @@ -298,7 +298,7 @@ export async function buildTargetedTouchResponsePayloads(params: { const referenceFrame = result.kind === 'point' ? await resolveDirectTouchReferenceFrameSafely({ - session, + ref: handlerParams.sessionRef!, flags: handlerParams.req.flags, sessionStore: handlerParams.sessionStore, contextFromFlags: handlerParams.contextFromFlags, 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/runtime-session.ts b/src/daemon/runtime-session.ts index 372c731ba3..f781c49fd3 100644 --- a/src/daemon/runtime-session.ts +++ b/src/daemon/runtime-session.ts @@ -1,7 +1,8 @@ import type { CommandSessionRecord, CommandSessionStore } from '../runtime-contract.ts'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { refFrameTree } from './ref-frame.ts'; -import type { SessionState } from './session-state.ts'; +import type { SessionRef, SessionState } from './session-state.ts'; +import type { SessionStore } from './session-store.ts'; export type RuntimeSessionRecordOptions = { includeSnapshot?: boolean; @@ -44,16 +45,38 @@ function toRuntimeSessionRecord( }; } +export function createReadonlyRuntimeSessionStore( + sessionName: string, + session: SessionState, +): CommandSessionStore { + return { + get: (name) => + name === sessionName ? toRuntimeSessionRecord(session, sessionName) : undefined, + set: () => {}, + }; +} + export function createDaemonRuntimeSessionStore(params: { sessionName: string; - getSession: () => SessionState | undefined; + sessionStore: SessionStore; + ref: SessionRef | undefined; recordOptions?: RuntimeSessionRecordOptions; - setRecord: (record: CommandSessionRecord) => void; -}): CommandSessionStore { + setRecord: ( + record: CommandSessionRecord, + current: SessionState | undefined, + ref: SessionRef | undefined, + ) => SessionRef | void; +}): CommandSessionStore & { getRef(): SessionRef | undefined } { + let ref = params.ref; return { + getRef: () => (ref ? params.sessionStore.refresh(ref) : undefined), get: (name) => name === params.sessionName - ? toRuntimeSessionRecord(params.getSession(), params.sessionName, params.recordOptions) + ? toRuntimeSessionRecord( + ref ? params.sessionStore.resolveCurrent(ref) : undefined, + params.sessionName, + params.recordOptions, + ) : undefined, set: (record) => { if (record.name !== params.sessionName) { @@ -64,7 +87,9 @@ export function createDaemonRuntimeSessionStore(params: { }); return; } - params.setRecord(record); + const current = ref ? params.sessionStore.requireCurrent(ref) : undefined; + const published = params.setRecord(record, current, ref); + if (published) ref = published; }, }; } diff --git a/src/daemon/screenshot-runtime.ts b/src/daemon/screenshot-runtime.ts index f10f44ab42..b07e829607 100644 --- a/src/daemon/screenshot-runtime.ts +++ b/src/daemon/screenshot-runtime.ts @@ -29,7 +29,7 @@ import type { RecordedGenericRequest, ResolvedGenericExecution, } from './request-generic-dispatch.ts'; -import { createDaemonRuntimeSessionStore } from './runtime-session.ts'; +import { createReadonlyRuntimeSessionStore } from './runtime-session.ts'; import { assertScreenshotCropPolicy } from './screenshot-crop-target.ts'; import { buildScreenshotCropWarnings, cropScreenshotToSelector } from './screenshot-crop.ts'; import { annotateScreenshotWithRefs } from '@agent-device/capture-kit/screenshot-overlay'; @@ -121,12 +121,7 @@ export async function captureScreenshotArtifact( const runtime = createCommandSurfaceAgentDevice({ backend: createBoundScreenshotBackend(params), artifacts: createDaemonScreenshotArtifactAdapter(), - sessions: createDaemonRuntimeSessionStore({ - sessionName, - getSession: () => session, - recordOptions: { includeSnapshot: false }, - setRecord: () => {}, - }), + sessions: createReadonlyRuntimeSessionStore(sessionName, session), policy: localCommandPolicy(), }); diff --git a/src/daemon/selector-capture-runtime.ts b/src/daemon/selector-capture-runtime.ts index 18a2664970..2cfd92c54d 100644 --- a/src/daemon/selector-capture-runtime.ts +++ b/src/daemon/selector-capture-runtime.ts @@ -8,7 +8,7 @@ import { } from '@agent-device/kernel/snapshot'; import { isSparseSnapshotQualityVerdict } from '@agent-device/capture-kit/snapshot-quality-verdict'; import type { DaemonRequest } from './daemon-request.ts'; -import type { SessionState } from './session-state.ts'; +import type { SessionRef, SessionState } from './session-state.ts'; import { SessionStore } from './session-store.ts'; import { recordCaptureProof } from './capture-disclosure.ts'; import type { RequestCaptureProof } from './capture-disclosure.ts'; @@ -24,6 +24,7 @@ import { isLegacySparseIosInteractiveSnapshot } from '@agent-device/selectors/ab const SELECTOR_CAPTURE_CACHE_TTL_MS = 750; export type SelectorCaptureRuntimeParams = { + ref: SessionRef | undefined; device: SessionState['device']; session: SessionState | undefined; sessionStore: SessionStore; @@ -81,7 +82,7 @@ type SelectorCaptureRequest = { type SelectorCaptureResult = BackendSnapshotResult & { snapshot: SnapshotState }; export function createSelectorCaptureRuntime(params: SelectorCaptureRuntimeParams) { - const { session, sessionStore, sessionName } = params; + const { sessionStore, ref } = params; let lastSnapshotAt = 0; let lastSnapshotResult: SelectorCaptureResult | undefined; let lastSnapshotCacheKey: string | undefined; @@ -92,6 +93,7 @@ export function createSelectorCaptureRuntime(params: SelectorCaptureRuntimeParam }; const capture = async (request: SelectorCaptureRequest): Promise => { + const session = ref ? sessionStore.requireCurrent(ref) : undefined; const timestamp = Date.now(); const cacheKey = selectorCaptureCacheKey(request, params.req.flags?.out); const reusableLastSnapshot = readReusableLastSnapshot({ @@ -118,7 +120,7 @@ export function createSelectorCaptureRuntime(params: SelectorCaptureRuntimeParam const snapshot = await captureSelectorSnapshot({ params, request }); request.signal?.throwIfAborted(); const result = { snapshot }; - updateSessionSnapshot({ session, sessionStore, sessionName, snapshot }); + updateSessionSnapshot({ ref, sessionStore, snapshot }); lastSnapshotAt = timestamp; lastSnapshotResult = result; lastSnapshotCacheKey = cacheKey; @@ -324,13 +326,11 @@ function flagsForPresentation(request: SelectorCaptureRequest): CommandFlags | u } function updateSessionSnapshot(params: { - session: SessionState | undefined; + ref: SessionRef | undefined; sessionStore: SessionStore; - sessionName: string; snapshot: SnapshotState; }): void { - const { session, sessionStore, sessionName, snapshot } = params; - if (!session || isSparseSnapshotQualityVerdict(snapshot.snapshotQuality)) return; - setSessionSnapshot(session, snapshot); - sessionStore.set(sessionName, session); + const { ref, sessionStore, snapshot } = params; + if (!ref || isSparseSnapshotQualityVerdict(snapshot.snapshotQuality)) return; + setSessionSnapshot(sessionStore.requireCurrent(ref), snapshot); } diff --git a/src/daemon/selector-runtime-backend.test.ts b/src/daemon/selector-runtime-backend.test.ts index e17551b3f4..977f32607a 100644 --- a/src/daemon/selector-runtime-backend.test.ts +++ b/src/daemon/selector-runtime-backend.test.ts @@ -52,6 +52,7 @@ test('wait text passes its poll deadline signal to the Apple runner fast path', }), ); const runtime = createSelectorRuntimeForDevice({ + ref: sessionStore.lookup(sessionName), req: { token: 't', session: sessionName, @@ -112,6 +113,7 @@ test('daemon wait stable pins private-ax on emitted snapshot runner requests', a }, })); const runtime = createSelectorRuntimeForDevice({ + ref: sessionStore.lookup(sessionName), req: { token: 't', session: sessionName, diff --git a/src/daemon/selector-runtime-backend.ts b/src/daemon/selector-runtime-backend.ts index 4af5d63305..1e87c6c47f 100644 --- a/src/daemon/selector-runtime-backend.ts +++ b/src/daemon/selector-runtime-backend.ts @@ -15,7 +15,7 @@ import { setSessionSnapshot } from './session-snapshot.ts'; import { markSessionSnapshotOutdated } from './ref-frame.ts'; import { SessionStore } from './session-store.ts'; import type { DaemonRequest, DaemonResponse } from './daemon-request.ts'; -import type { SessionState } from './session-state.ts'; +import type { SessionRef, SessionState } from './session-state.ts'; import { createSelectorCaptureRuntime } from './selector-capture-runtime.ts'; import { buildRuntimeCaptureInput } from './snapshot-runtime-capture-input.ts'; import { @@ -51,6 +51,7 @@ export type SelectorRuntimeParams = { }; export type SelectorRuntimeDeviceParams = SelectorRuntimeParams & { + ref: SessionRef | undefined; session: SessionState | undefined; device: SessionState['device']; /** @@ -63,11 +64,20 @@ export type SelectorRuntimeDeviceParams = SelectorRuntimeParams & { }; type ResolvedSelectorRuntime = - | { ok: true; runtime: ReturnType } + | { + ok: true; + ref: SessionRef | undefined; + runtime: ReturnType; + } | { ok: false; response: DaemonResponse }; type ResolvedSelectorDevice = - | { ok: true; session: SessionState | undefined; device: SessionState['device'] } + | { + ok: true; + ref: SessionRef | undefined; + session: SessionState | undefined; + device: SessionState['device']; + } | { ok: false; response: DaemonResponse }; export function createSelectorRuntimeForDevice(params: SelectorRuntimeDeviceParams) { @@ -76,12 +86,12 @@ export function createSelectorRuntimeForDevice(params: SelectorRuntimeDevicePara ...createDaemonRuntimePolicy('selector commands', { plural: true }), sessions: createDaemonRuntimeSessionStore({ sessionName: params.sessionName, - getSession: () => params.session, + sessionStore: params.sessionStore, + ref: params.ref, recordOptions: { includeSnapshot: true }, - setRecord: (record) => { - if (!params.session || !record.snapshot) return; - setSessionSnapshot(params.session, record.snapshot); - params.sessionStore.set(params.sessionName, params.session); + setRecord: (record, current) => { + if (!current || !record.snapshot) return; + setSessionSnapshot(current, record.snapshot); }, }), signal: params.signal ?? getRequestSignal(params.req.meta?.requestId), @@ -95,10 +105,11 @@ async function resolveSelectorRuntimeDevice( ): Promise { params.consumedSnapshot ??= {}; params.captureProof ??= {}; - const session = params.sessionStore.get(params.sessionName); + const ref = params.sessionStore.lookup(params.sessionName); + const session = ref?.session; if (!session && requireSession) return { ok: false, response: noActiveSessionError() }; const device = session?.device ?? (await resolveTargetDevice(params.req.flags ?? {})); - return { ok: true, session, device }; + return { ok: true, ref, session, device }; } /** @@ -130,8 +141,10 @@ export async function createBoundSelectorRuntime( if (!bound.ok) return { ok: false, response: bound.response }; return { ok: true, + ref: resolved.ref, runtime: createSelectorRuntimeForDevice({ ...params, + ref: resolved.ref, session: resolved.session, device: resolved.device, bound: bound.operations, @@ -166,6 +179,7 @@ function createSelectorBackend(params: SelectorRuntimeDeviceParams): AgentDevice boundOperations === undefined ? undefined : createSelectorCaptureRuntime({ + ref: params.ref, device, session, sessionStore, diff --git a/src/daemon/selector-runtime.ts b/src/daemon/selector-runtime.ts index 13ea73daa6..4ad7685b04 100644 --- a/src/daemon/selector-runtime.ts +++ b/src/daemon/selector-runtime.ts @@ -85,13 +85,13 @@ export async function dispatchFindReadOnlyViaRuntime( .filter((ref): ref is string => typeof ref === 'string') : []; if (publishedRefs.length > 0) { - const session = params.sessionStore.get(params.sessionName); - if (session) { + const ref = resolvedRuntime.ref; + if (ref) { + const session = params.sessionStore.requireCurrent(ref); // ADR 0014: a read-only find publishes exactly the refs it returned — // one for single-match actions, every listed ref for `list` — so it // activates a PARTIAL frame authorizing exactly those ref bodies. markSessionPartialRefsIssued(session, publishedRefs); - params.sessionStore.set(params.sessionName, session); if (session.snapshotGeneration !== undefined) { return { ...data, refsGeneration: session.snapshotGeneration }; } diff --git a/src/daemon/server/daemon-runtime-snapshot-shutdown.test.ts b/src/daemon/server/daemon-runtime-snapshot-shutdown.test.ts new file mode 100644 index 0000000000..d5e0193395 --- /dev/null +++ b/src/daemon/server/daemon-runtime-snapshot-shutdown.test.ts @@ -0,0 +1,125 @@ +import { afterEach, expect, test, vi } from 'vitest'; +import { + clearRequestAbortRegistration, + markRequestCanceled, + registerRequestAbort, +} from '@agent-device/host-kit/request'; +import { ANDROID_EMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; +import { makeAndroidSession } from '../../__tests__/test-utils/session-factories.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { legacyDispatchCapture } from '../__tests__/legacy-snapshot-capture-fixture.ts'; +import { snapshotRuntimeFixture } from '../__tests__/snapshot-runtime-fixture.ts'; +import { platformResourceCleanup } from '../../platform-runtime-resource-cleanup.ts'; +import { DAEMON_SESSION_TEARDOWN_TIMEOUT_MS } from '../session-teardown-budget.ts'; +import { dispatchSnapshotViaRuntime } from '../snapshot-runtime.ts'; +import { teardownDaemonSessionForShutdown } from './daemon-runtime.ts'; + +vi.mock('@agent-device/device-selection/dispatch-resolve', () => ({ + resolveTargetDevice: vi.fn(async () => ANDROID_EMULATOR), +})); +vi.mock('../device/device-ready.ts', () => ({ ensureDeviceReady: vi.fn(async () => {}) })); +vi.mock('../../platform-runtime-resource-cleanup.ts', () => ({ + platformResourceCleanup: { + stopSnapshotHelper: vi.fn(), + closeManagedBrowser: vi.fn(async () => {}), + cleanupSessionlessExecutionHost: vi.fn(async () => {}), + retainExecutionHostAfterClose: vi.fn(() => false), + }, +})); + +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + legacyDispatchCapture.mockReset(); +}); + +for (const initialSession of ['published', 'draft'] as const) { + test(`bounded shutdown refuses a late ${initialSession} snapshot`, async () => { + const sessionStore = makeSessionStore(); + const address = 'cwd:shutdown-capture:default'; + const shutdownRef = sessionStore.publish( + initialSession === 'published' ? address : 'shutdown-owner', + makeAndroidSession('default'), + ); + const captureEntered = deferred(); + const releaseCapture = deferred(); + const cleanupEntered = deferred(); + const releaseCleanup = deferred(); + const registration = registerRequestAbort(`shutdown-snapshot-${initialSession}`)!; + legacyDispatchCapture.mockImplementation(async () => { + captureEntered.resolve(); + await releaseCapture.promise; + return { nodes: [], truncated: false, backend: 'uiautomator' }; + }); + vi.mocked(platformResourceCleanup.stopSnapshotHelper).mockImplementation(async () => { + cleanupEntered.resolve(); + await releaseCleanup.promise; + }); + const running = dispatchSnapshotViaRuntime({ + req: { + command: 'snapshot', + positionals: [], + token: 'test', + session: address, + meta: { requestId: registration.requestId }, + }, + sessionName: address, + logPath: '/dev/null', + sessionStore, + ...snapshotRuntimeFixture(registration.requestId), + platformResourceCleanup, + }); + const result = running.then( + (response) => ({ response }), + (error: unknown) => ({ error }), + ); + const stderr: string[] = []; + let teardown: Promise | undefined; + try { + await captureEntered.promise; + vi.useFakeTimers(); + sessionStore.closeAdmission(); + markRequestCanceled(registration.requestId); + teardown = teardownDaemonSessionForShutdown({ + ref: shutdownRef, + sessionStore, + stderr: { write: (chunk) => stderr.push(chunk) }, + }); + await cleanupEntered.promise; + await vi.advanceTimersByTimeAsync(DAEMON_SESSION_TEARDOWN_TIMEOUT_MS); + await teardown; + expect(stderr.join('')).toContain('Daemon session teardown timed out (default).'); + expect(sessionStore.resolveCurrent(shutdownRef)).toBeUndefined(); + expect(registration.controller.signal.aborted).toBe(true); + releaseCapture.resolve(); + expect(await result).toMatchObject({ + error: { + details: { + reason: + initialSession === 'published' ? 'session_lifetime_ended' : 'daemon_shutting_down', + }, + }, + }); + expect(sessionStore.lookup(address)).toBeUndefined(); + expect(sessionStore.lookup('default')).toBeUndefined(); + expect(platformResourceCleanup.stopSnapshotHelper).toHaveBeenCalledExactlyOnceWith( + ANDROID_EMULATOR, + ); + } finally { + releaseCapture.resolve(); + releaseCleanup.resolve(); + await result; + await teardown; + clearRequestAbortRegistration(registration); + vi.useRealTimers(); + } + }); +} diff --git a/src/daemon/session-snapshot.ts b/src/daemon/session-snapshot.ts index 0f0bb71822..9e576fcbf5 100644 --- a/src/daemon/session-snapshot.ts +++ b/src/daemon/session-snapshot.ts @@ -16,8 +16,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 +37,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 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, From 9b22713f137ff6de81cd92f3289785c89788781b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 08:51:19 +0200 Subject: [PATCH 29/44] fix: publish settle refs through the session lifetime --- src/daemon/generic-settle.ts | 6 +- src/daemon/handlers/react-native.ts | 2 +- .../__tests__/interaction-settle.test.ts | 82 +++++++++++++++++-- .../internal/interaction-touch-fill.ts | 16 ++-- .../internal/interaction-touch-response.ts | 13 ++- src/daemon/request-generic-dispatch.ts | 5 +- src/daemon/session-snapshot.ts | 9 +- 7 files changed, 111 insertions(+), 22 deletions(-) diff --git a/src/daemon/generic-settle.ts b/src/daemon/generic-settle.ts index e18ca7f965..70e1ead5d6 100644 --- a/src/daemon/generic-settle.ts +++ b/src/daemon/generic-settle.ts @@ -15,7 +15,7 @@ import type { BoundContextFromFlags } from './context.ts'; import { issueSettleRefs } from './session-snapshot.ts'; import type { SessionStore } from './session-store.ts'; import type { DaemonRequest, DaemonResponse } from './daemon-request.ts'; -import type { SessionState } from './session-state.ts'; +import type { SessionRef, SessionState } from './session-state.ts'; /** * `--settle` on the generic daemon route (#1638): `scroll` and `back` change @@ -55,6 +55,7 @@ export type GenericSettleObserver = () => Promise export type GenericSettlePlan = { response: DaemonResponse } | { observe?: GenericSettleObserver }; type GenericSettleContext = { + sessionRef: SessionRef | undefined; req: DaemonRequest; session: SessionState; sessionName: string; @@ -109,7 +110,7 @@ async function observeSettled( session: context.sessionName, requestId: context.req.meta?.requestId, }); - const refsGeneration = issueSettleRefs(context.session, observation); + const refsGeneration = issueSettleRefs(context.sessionRef, context.sessionStore, observation); return refsGeneration === undefined ? observation : { ...observation, refsGeneration }; } @@ -120,6 +121,7 @@ function createGenericSettleRuntime( return createInteractionRuntime({ req: context.req, sessionName: context.sessionName, + sessionRef: context.sessionRef, logPath: context.logPath, sessionStore: context.sessionStore, contextFromFlags: context.contextFromFlags, diff --git a/src/daemon/handlers/react-native.ts b/src/daemon/handlers/react-native.ts index a7294d39dc..028fec887b 100644 --- a/src/daemon/handlers/react-native.ts +++ b/src/daemon/handlers/react-native.ts @@ -1,4 +1,3 @@ -import { bindInteractionSession } from '../interaction/index.ts'; import { createBoundTouchExecutor, resolveBoundTouchRuntime, @@ -18,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, 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/interaction-touch-fill.ts b/src/daemon/interaction/internal/interaction-touch-fill.ts index d7825b5d2f..5d6523125a 100644 --- a/src/daemon/interaction/internal/interaction-touch-fill.ts +++ b/src/daemon/interaction/internal/interaction-touch-fill.ts @@ -4,7 +4,8 @@ import type { FillCommandResult, InteractionTarget } from '@agent-device/contrac 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'; @@ -52,7 +53,7 @@ export async function dispatchFillViaRuntime(params: FillParams): Promise buildFillResponsePayloads({ - session, + ref: params.sessionRef!, + sessionStore: params.sessionStore, result, text: parsedTarget.text, flags: req.flags, @@ -200,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, @@ -229,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-response.ts b/src/daemon/interaction/internal/interaction-touch-response.ts index 5aa80eaa47..e7235e216e 100644 --- a/src/daemon/interaction/internal/interaction-touch-response.ts +++ b/src/daemon/interaction/internal/interaction-touch-response.ts @@ -294,7 +294,7 @@ 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({ @@ -305,13 +305,20 @@ export async function buildTargetedTouchResponsePayloads(params: { 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/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/session-snapshot.ts b/src/daemon/session-snapshot.ts index 9e576fcbf5..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 @@ -120,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; } From e4ca3d1de1d093c6ebbfccd382f79c332ae7c82b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 08:53:42 +0200 Subject: [PATCH 30/44] chore(gates): declare the interaction lifetime binding facade --- scripts/layering/architecture-ownership.ts | 1 + 1 file changed, 1 insertion(+) 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', From 37b9ae2417073e11adb39ef50cdd10dcaf95e32c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 09:17:48 +0200 Subject: [PATCH 31/44] fix: retain session and registration ownership through review controls --- .../src/durable-capture/transitions.test.ts | 9 ++++- .../daemon-registration-owner.test.ts | 21 +++++----- src/__tests__/test-utils/store-factory.ts | 10 +++-- .../__tests__/session-capture-binding.test.ts | 5 ++- .../__tests__/session-store-lifetime.test.ts | 40 ++++++++++++++++++- .../screen-recording-session-binding.ts | 3 +- src/daemon/session-store.ts | 7 ++-- src/session-repair-tombstone.ts | 26 ++++++++---- 8 files changed, 90 insertions(+), 31 deletions(-) diff --git a/packages/capture-kit/src/durable-capture/transitions.test.ts b/packages/capture-kit/src/durable-capture/transitions.test.ts index 0ff01c1062..d50c0e3c02 100644 --- a/packages/capture-kit/src/durable-capture/transitions.test.ts +++ b/packages/capture-kit/src/durable-capture/transitions.test.ts @@ -220,7 +220,7 @@ test('a disposal finish disposes a preserving kind’s material too', async () = }); }); -test.each(['rebuild', 'retire', 'token', 'generation'] as const)( +test.each(['rebuild', 'retire', 'handle', 'token', 'generation'] as const)( 'a held finish after %s clears only its matching lifetime, handle and fence', async (change) => { const context = makeDurableCaptureContext(); @@ -270,7 +270,12 @@ test.each(['rebuild', 'retire', 'token', 'generation'] as const)( context.sessionStore.update(ref, (current) => ({ ...current, name: 'updated', - capture: { ...active, envelope: { ...active.envelope, fence } }, + capture: { + ...active, + handle: + change === 'handle' ? makeDurableCaptureStartResult(context).handle : active.handle, + envelope: { ...active.envelope, fence }, + }, })); } const before = context.sessionStore.get(context.sessionName)!; diff --git a/src/__tests__/daemon-registration-owner.test.ts b/src/__tests__/daemon-registration-owner.test.ts index 5426a07764..8af5ffc729 100644 --- a/src/__tests__/daemon-registration-owner.test.ts +++ b/src/__tests__/daemon-registration-owner.test.ts @@ -484,12 +484,15 @@ async function waitForCutoverFixture(ready: () => boolean): Promise { 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; +function legacyDisposition(paths: DaemonPaths): boolean | undefined { + try { + const parsed = JSON.parse( + fs.readFileSync(path.join(paths.baseDir, 'legacy-disposition.json'), 'utf8'), + ) as { acquired?: unknown }; + return typeof parsed.acquired === 'boolean' ? parsed.acquired : undefined; + } catch { + return undefined; + } } test('cutover refuses an already-running legacy daemon before signaling or changing registration', async () => { @@ -542,9 +545,7 @@ test('a legacy contender cannot unlink a hardened owner while it delays metadata 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')), - ); + await waitForCutoverFixture(() => legacyDisposition(paths) !== undefined); assert.equal(legacyDisposition(paths), false); await legacy.exited; const claim = inspectProcessLock(paths.lockPath); @@ -580,7 +581,7 @@ test('concurrent old and new daemon startup has one owner at the shared lock pat fs.writeFileSync(barrier, 'start'); await waitForCutoverFixture( () => - fs.existsSync(path.join(paths.baseDir, 'legacy-disposition.json')) && + legacyDisposition(paths) !== undefined && (currentExited || fs.existsSync(path.join(paths.baseDir, 'registration-held'))), ); const oldAcquired = legacyDisposition(paths); diff --git a/src/__tests__/test-utils/store-factory.ts b/src/__tests__/test-utils/store-factory.ts index cc5a128879..c646a9b861 100644 --- a/src/__tests__/test-utils/store-factory.ts +++ b/src/__tests__/test-utils/store-factory.ts @@ -12,9 +12,13 @@ export function makeStoredSessionRef(session: SessionState, address = session.na 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); +export function storeSessionForTest( + store: SessionStore, + session: SessionState, + address = session.name, +): SessionRef { + const ref = store.lookup(address); + if (!ref) return store.publish(address, session); if (ref.session !== session) throw new Error('A different test session occupies this address'); return ref; } diff --git a/src/daemon/__tests__/session-capture-binding.test.ts b/src/daemon/__tests__/session-capture-binding.test.ts index 1058b0f354..a4a0d5039a 100644 --- a/src/daemon/__tests__/session-capture-binding.test.ts +++ b/src/daemon/__tests__/session-capture-binding.test.ts @@ -73,9 +73,10 @@ test('clearing an older handle or fence leaves a replacement capture intact', () sessionStore: store, finish: async () => ({ status: 'cleanup-pending', reason: 'cleanup-unconfirmed' }), }).screenRecording!; - store.update(ref, { screenRecording: replacement }); + const differentHandle = { ...active, handle: replacement.handle }; + store.update(ref, { screenRecording: differentHandle }); expect(binding.clear(active)).toBe('resource-changed'); - expect(binding.read()).toBe(replacement); + expect(binding.read()).toBe(differentHandle); for (const fence of [ { ...active.envelope.fence, token: 'next' }, { ...active.envelope.fence, generation: active.envelope.fence.generation + 1 }, diff --git a/src/daemon/__tests__/session-store-lifetime.test.ts b/src/daemon/__tests__/session-store-lifetime.test.ts index f834cc2f96..ef1315fa6d 100644 --- a/src/daemon/__tests__/session-store-lifetime.test.ts +++ b/src/daemon/__tests__/session-store-lifetime.test.ts @@ -8,7 +8,8 @@ import { makeRepairArmedSession, authoringPublication, } from '../../__tests__/test-utils/session-factories.ts'; -import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { makeSessionStore, storeSessionForTest } from '../../__tests__/test-utils/store-factory.ts'; +import { resolveRepairTombstonePath } from '../../session-repair-tombstone.ts'; const ADDRESS = 'cwd:worktree:default'; @@ -171,3 +172,40 @@ test('repair tombstones follow the scoped address and cannot be written by a ret assert.equal(store.readRepairTombstone(ADDRESS), undefined); assert.equal(store.requireCurrent(successor), successor.session); }); + +test('test session publication uses its explicit scoped address', () => { + const store = makeSessionStore(); + const session = makeSession('default'); + const ref = store.publish(ADDRESS, session); + const stored = storeSessionForTest(store, session, ADDRESS); + assert.equal(stored.lifetime, ref.lifetime); + assert.equal(stored.session, session); + assert.equal(store.get('default'), undefined); + assert.equal(store.listRefs().length, 1); +}); + +test('colliding artifact directories cannot share or clear another address\u2019s repair tombstone', () => { + const store = makeSessionStore(); + const collision = 'cwd_worktree_default'; + const ref = store.publish(ADDRESS, makeRepairArmedSession('default')); + assert.equal(store.resolveSessionDir(ADDRESS), store.resolveSessionDir(collision)); + store.writeRepairTombstone(ref); + assert.equal(store.readRepairTombstone(ADDRESS)?.owner, ADDRESS); + assert.equal(store.readRepairTombstone(collision), undefined); + store.clearRepairTombstone(collision); + assert.equal(store.readRepairTombstone(ADDRESS)?.owner, ADDRESS); + store.clearRepairTombstone(ADDRESS); + assert.equal(store.readRepairTombstone(ADDRESS), undefined); +}); + +test('tombstone cleanup retains malformed evidence and removes an expired owned marker', () => { + const store = makeSessionStore(); + const tombstonePath = resolveRepairTombstonePath(store.resolveSessionDir(ADDRESS)); + fs.mkdirSync(store.resolveSessionDir(ADDRESS), { recursive: true }); + fs.writeFileSync(tombstonePath, '{'); + store.clearRepairTombstone(ADDRESS); + assert.equal(fs.readFileSync(tombstonePath, 'utf8'), '{'); + fs.writeFileSync(tombstonePath, JSON.stringify({ owner: ADDRESS, expiresAt: 0, reapedAt: 0 })); + store.clearRepairTombstone(ADDRESS); + assert.equal(fs.existsSync(tombstonePath), false); +}); diff --git a/src/daemon/screen-recording-session-binding.ts b/src/daemon/screen-recording-session-binding.ts index b5d56813d0..4c4505b3ee 100644 --- a/src/daemon/screen-recording-session-binding.ts +++ b/src/daemon/screen-recording-session-binding.ts @@ -17,8 +17,7 @@ export function bindRecordOnlyScreenRecording( canPersist: () => !published && sessionStore.lookup(address) === undefined, adopt: (screenRecording) => { sessionStore.assertPublishable(address); - draft.screenRecording = screenRecording; - published = sessionStore.publish(address, draft); + published = sessionStore.publish(address, { ...draft, screenRecording }); }, clear: (expected) => published ? bindSessionScreenRecording(sessionStore, published).clear(expected) : 'retired', diff --git a/src/daemon/session-store.ts b/src/daemon/session-store.ts index 00baf86df3..e6818d4071 100644 --- a/src/daemon/session-store.ts +++ b/src/daemon/session-store.ts @@ -12,6 +12,7 @@ import { } from './session-artifact-paths.ts'; import { readRepairTombstoneFile, + clearRepairTombstoneFile, resolveRepairTombstonePath, type RepairSessionTombstone, } from '../session-repair-tombstone.ts'; @@ -357,14 +358,12 @@ export class SessionStore { /** Returns a non-expired repair tombstone for `sessionName`, or `undefined`. */ readRepairTombstone(sessionName: string): RepairSessionTombstone | undefined { - return readRepairTombstoneFile(this.repairTombstonePath(sessionName)); + return readRepairTombstoneFile(this.repairTombstonePath(sessionName), sessionName); } /** ADR 0012 R7 (C5a): a fresh `replay --save-script` on this key clears the tombstone. */ clearRepairTombstone(sessionName: string): void { - try { - fs.rmSync(this.repairTombstonePath(sessionName), { force: true }); - } catch {} + clearRepairTombstoneFile(this.repairTombstonePath(sessionName), sessionName); } /** diff --git a/src/session-repair-tombstone.ts b/src/session-repair-tombstone.ts index a0fae0461b..dedf854736 100644 --- a/src/session-repair-tombstone.ts +++ b/src/session-repair-tombstone.ts @@ -31,15 +31,28 @@ 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 { +export function readRepairTombstoneFile( + tombstonePath: string, + owner: string, +): RepairSessionTombstone | undefined { try { - return readRepairTombstoneForCleanup(tombstonePath); + const tombstone = readRepairTombstone(tombstonePath); + return tombstone?.owner === owner && tombstone.expiresAt > Date.now() ? tombstone : undefined; } catch { return undefined; } } -function readRepairTombstoneForCleanup(tombstonePath: string): RepairSessionTombstone | undefined { +/** Removes only a parseable marker belonging to the requested session, including expired markers. */ +export function clearRepairTombstoneFile(tombstonePath: string, owner: string): void { + try { + if (readRepairTombstone(tombstonePath)?.owner === owner) { + fs.rmSync(tombstonePath, { force: true }); + } + } catch {} +} + +function readRepairTombstone(tombstonePath: string): RepairSessionTombstone | undefined { let raw: string; try { raw = fs.readFileSync(tombstonePath, 'utf8'); @@ -47,8 +60,7 @@ function readRepairTombstoneForCleanup(tombstonePath: string): RepairSessionTomb if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; throw error; } - const parsed = parseRepairTombstone(raw, tombstonePath); - return parsed.expiresAt > Date.now() ? parsed : undefined; + return parseRepairTombstone(raw, tombstonePath); } function parseRepairTombstone(raw: string, tombstonePath: string): RepairSessionTombstone { @@ -110,10 +122,10 @@ export function findUnrecoveredRepairCommitFailure(sessionsDir: string): } for (const entry of entries) { if (!entry.isDirectory()) continue; - const tombstone = readRepairTombstoneForCleanup( + const tombstone = readRepairTombstone( resolveRepairTombstonePath(path.join(sessionsDir, entry.name)), ); - if (tombstone?.commitFailure) { + if (tombstone?.commitFailure && tombstone.expiresAt > Date.now()) { return { sessionName: entry.name, tombstone: { ...tombstone, commitFailure: tombstone.commitFailure }, From 6c3e88b95d175bc78f8691e86618e99351d30907 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 09:17:48 +0200 Subject: [PATCH 32/44] chore(gates): declare the record-only capture publication owner --- scripts/layering/session-resource-ownership.test.ts | 4 ++++ scripts/layering/session-resource-ownership.ts | 1 + 2 files changed, 5 insertions(+) diff --git a/scripts/layering/session-resource-ownership.test.ts b/scripts/layering/session-resource-ownership.test.ts index 8a1453e6cb..a85922db6c 100644 --- a/scripts/layering/session-resource-ownership.test.ts +++ b/scripts/layering/session-resource-ownership.test.ts @@ -32,6 +32,10 @@ test('session resources are constructed only by their durable domain owners', () sessionStore.update(ref, { perfCapture: perf }); sessionStore.update(ref, { screenRecording: recording });`, ], + [ + 'src/daemon/screen-recording-session-binding.ts', + `sessionStore.publish(address, { ...draft, screenRecording });`, + ], [ 'packages/capture-kit/src/capture-admission/audio-probe-session-resource.ts', `sessionStore.set(name, { ...session, audioProbe: audio });`, diff --git a/scripts/layering/session-resource-ownership.ts b/scripts/layering/session-resource-ownership.ts index 323d8af3be..77aa399444 100644 --- a/scripts/layering/session-resource-ownership.ts +++ b/scripts/layering/session-resource-ownership.ts @@ -30,6 +30,7 @@ const RESOURCE_OWNERS: Readonly>> = { audioProbe: new Set(['src/daemon/session-capture-binding.ts', 'src/daemon/session-state.ts']), screenRecording: new Set([ 'src/daemon/session-capture-binding.ts', + 'src/daemon/screen-recording-session-binding.ts', 'src/daemon/session-state.ts', ]), perfCapture: new Set(['src/daemon/session-capture-binding.ts', 'src/daemon/session-state.ts']), From a679c1e3f3a500fde1b11424533779875ac06fc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 09:30:44 +0200 Subject: [PATCH 33/44] fix: prepare recording health within the captured session lifetime --- .../request-recording-health.test.ts | 145 +++++++++++++++++- src/daemon/request-binding.ts | 5 +- src/daemon/request-execution-scope.ts | 18 ++- src/daemon/request-recording-health.ts | 12 +- 4 files changed, 162 insertions(+), 18 deletions(-) diff --git a/src/daemon/__tests__/request-recording-health.test.ts b/src/daemon/__tests__/request-recording-health.test.ts index 73000c5efa..d2dd73d068 100644 --- a/src/daemon/__tests__/request-recording-health.test.ts +++ b/src/daemon/__tests__/request-recording-health.test.ts @@ -1,5 +1,11 @@ import { test, expect, vi, beforeEach } from 'vitest'; import type { SessionState } from '../session-state.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { + createRequestExecutionScope, + prepareLockedRequestScope, +} from '../request-execution-scope.ts'; +import { LeaseRegistry } from '../lease-registry.ts'; import { makeTestScreenRecordingResource } from '../../__tests__/test-utils/screen-recording-live-handle.ts'; vi.mock('../../platform-runtime-apple-resources.ts', async (importOriginal) => ({ @@ -54,7 +60,9 @@ test('runner-backed iOS recordings still invalidate on runner restarts', async ( sessionId: 'runner-after', }); - await refreshRecordingHealth(session); + const store = makeSessionStore(); + const ref = store.publish(session.name, session); + await refreshRecordingHealth(store, ref); expect(mockObserveRunnerSession).toHaveBeenCalledWith('sim-1'); expect(session.screenRecording?.handle.inspect().invalidatedReason).toBe( @@ -78,7 +86,9 @@ test.each([ }); mockObserveRunnerSession.mockResolvedValue(snapshot); - await refreshRecordingHealth(session); + const store = makeSessionStore(); + const ref = store.publish(session.name, session); + await refreshRecordingHealth(store, ref); expect(session.screenRecording.handle.inspect().invalidatedReason).toBe(reason); }); @@ -91,9 +101,138 @@ test('a recording without a runner identity adopts the first live observation', }); mockObserveRunnerSession.mockResolvedValue({ alive: true, sessionId: 'runner-first' }); - await refreshRecordingHealth(session); + const store = makeSessionStore(); + const ref = store.publish(session.name, session); + await refreshRecordingHealth(store, ref); const recording = session.screenRecording.handle.inspect(); expect(recording.runnerSessionId).toBe('runner-first'); expect(recording.invalidatedReason).toBeUndefined(); }); + +test.each(['rebuild', 'retire', 'handle', 'token', 'generation'] as const)( + 'a held health observation respects the current lifetime and resource: %s', + async (change) => { + const store = makeSessionStore(); + const session = makeIosSimulatorSession(true); + const active = makeTestScreenRecordingResource(session, { + backend: 'runner AVAssetWriter', + showTouches: true, + runnerSessionId: 'runner-before', + }); + session.screenRecording = active; + const ref = store.publish('default', session); + let finish!: (value: { alive: boolean; sessionId: string }) => void; + mockObserveRunnerSession.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const observation = refreshRecordingHealth(store, ref); + expect(mockObserveRunnerSession).toHaveBeenCalledWith('sim-1'); + if (change === 'rebuild') { + store.update(ref, { appName: 'Intervening app' }); + } else if (change === 'retire') { + store.retire(ref); + store.publish('default', session); + } else { + const replacement = makeTestScreenRecordingResource(session, { + backend: 'runner AVAssetWriter', + showTouches: true, + runnerSessionId: 'successor-runner', + }); + store.update(ref, { + screenRecording: { + ...active, + handle: change === 'handle' ? replacement.handle : active.handle, + envelope: { + ...active.envelope, + fence: { + ...active.envelope.fence, + token: change === 'token' ? 'new-token' : active.envelope.fence.token, + generation: active.envelope.fence.generation + (change === 'generation' ? 1 : 0), + }, + }, + }, + }); + } + const current = store.get('default')!; + finish({ alive: true, sessionId: 'runner-after' }); + await observation; + expect(store.get('default')).toBe(current); + expect(active.handle.inspect().invalidatedReason).toBe( + change === 'rebuild' ? 'iOS runner session restarted during recording' : undefined, + ); + if (change === 'rebuild') expect(current.appName).toBe('Intervening app'); + else expect(current.screenRecording?.handle.inspect().invalidatedReason).toBeUndefined(); + }, +); + +test.each(['rebuild', 'retire'] as const)( + 'locked request preparation keeps its captured lifetime after runner observation: %s', + async (change) => { + const store = makeSessionStore(); + const session = makeIosSimulatorSession(true); + session.screenRecording = makeTestScreenRecordingResource(session, { + backend: 'runner AVAssetWriter', + showTouches: true, + runnerSessionId: 'runner-before', + }); + const ref = store.publish('default', session); + let observed!: () => void; + const started = new Promise((resolve) => { + observed = resolve; + }); + let finish!: (value: { alive: boolean; sessionId: string }) => void; + mockObserveRunnerSession.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + observed(); + }), + ); + await using scope = await createRequestExecutionScope({ + req: { token: 'token', session: 'default', command: 'snapshot', positionals: [] }, + sessionStore: store, + leaseRegistry: new LeaseRegistry(), + }); + const prepared = scope.runLocked(() => + prepareLockedRequestScope({ + scope, + sessionStore: store, + trackDownloadableArtifact: () => 'artifact', + }), + ); + const outcome = prepared.then( + (value) => ({ value }), + (error) => ({ error }), + ); + await started; + let current; + if (change === 'rebuild') current = store.update(ref, { appName: 'Latest app' }); + else { + store.retire(ref); + current = makeIosSimulatorSession(false); + store.publish('default', current); + store.setRuntimeHints('default', { metroPort: 8083 }); + } + finish({ alive: true, sessionId: 'runner-before' }); + const result = await outcome; + expect(store.get('default')).toBe(current); + if (change === 'rebuild') { + expect(result).toMatchObject({ + value: { type: 'scope', scope: { existingSession: current } }, + }); + expect(current.appName).toBe('Latest app'); + if ('value' in result && result.value.type === 'scope') { + store.retire(ref); + store.publish('default', { ...makeIosSimulatorSession(false), surface: 'app' }); + expect(result.value.scope.handlerContextFromFlags(undefined).surface).toBeUndefined(); + } + } else { + expect(result).toMatchObject({ error: { details: { reason: 'session_lifetime_ended' } } }); + expect(store.getRuntimeHints('default')).toEqual({ metroPort: 8083 }); + } + }, +); diff --git a/src/daemon/request-binding.ts b/src/daemon/request-binding.ts index 92cffd5cf0..1c030b53c1 100644 --- a/src/daemon/request-binding.ts +++ b/src/daemon/request-binding.ts @@ -64,10 +64,9 @@ export async function resolveRequestExecutionLockPlan(params: { export function prepareLockedRequestBinding(params: { req: DaemonRequest; - sessionName: string; - sessionStore: SessionStore; + existingRef: SessionRef | undefined; }): LockedRequestBinding { - const existingRef = params.sessionStore.lookup(params.sessionName); + const { existingRef } = params; return { req: applyRequestLockPolicy(params.req, existingRef), existingRef, diff --git a/src/daemon/request-execution-scope.ts b/src/daemon/request-execution-scope.ts index 4430b38d86..56423054cb 100644 --- a/src/daemon/request-execution-scope.ts +++ b/src/daemon/request-execution-scope.ts @@ -486,16 +486,15 @@ export async function prepareLockedRequestScope(params: { const { scope, sessionStore, trackDownloadableArtifact } = params; const logPath = scope.runnerLogPath; scope.throwIfCanceled(); - const seededSession = sessionStore.get(scope.sessionName); - if (seededSession) { - // Called under runLocked: refreshRecordingHealth may mutate session recording state. - await refreshRecordingHealth(seededSession); - sessionStore.set(scope.sessionName, seededSession); + const seededRef = sessionStore.lookup(scope.sessionName); + if (seededRef) { + await refreshRecordingHealth(sessionStore, seededRef); + scope.throwIfCanceled(); + sessionStore.requireCurrent(seededRef); } const binding = prepareLockedRequestBinding({ req: scope.req, - sessionName: scope.sessionName, - sessionStore, + existingRef: seededRef ? sessionStore.refresh(seededRef) : undefined, }); const lockedReq = binding.req; // `scope.sessionName` is the resolved store key, so `existingRef` carries the address every @@ -563,7 +562,10 @@ export async function prepareLockedRequestScope(params: { ({ ...contextFromFlags(flags, appBundleId, traceLogPath), // Handlers may update surface during the request, so read the current session state. - surface: sessionStore.get(scope.sessionName)?.surface, + surface: (seededRef + ? sessionStore.resolveCurrent(seededRef) + : sessionStore.get(scope.sessionName) + )?.surface, }) satisfies DaemonCommandContext, }, }; diff --git a/src/daemon/request-recording-health.ts b/src/daemon/request-recording-health.ts index 399c005fbf..df3db9e55a 100644 --- a/src/daemon/request-recording-health.ts +++ b/src/daemon/request-recording-health.ts @@ -1,15 +1,19 @@ import { isIosFamily } from '@agent-device/kernel/device'; import { appleSessionObservation } from '../platform-runtime-apple-resources.ts'; -import type { SessionState } from './session-state.ts'; +import type { SessionRef, SessionState } from './session-state.ts'; +import type { SessionStore } from './session-store.ts'; -export async function refreshRecordingHealth(session: SessionState): Promise { +export async function refreshRecordingHealth(store: SessionStore, ref: SessionRef): Promise { + const session = store.requireCurrent(ref); if (!recordingRequiresRunnerHealth(session)) { return; } - const recording = session.screenRecording!.handle; - const state = recording.inspect(); + const resource = session.screenRecording!; + const recording = resource.handle; const snapshot = await appleSessionObservation.observeRunnerSession(session.device.id); + if (store.resolveCurrent(ref)?.screenRecording !== resource) return; + const state = recording.inspect(); if (!state.runnerSessionId) { if (snapshot?.alive) { recording.setRunnerSessionId(snapshot.sessionId); From a6d6f14dd49006cc05c8dc7b8c6bdee41ccb182d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 09:41:09 +0200 Subject: [PATCH 34/44] fix: bind replay observations to the captured session lifetime --- .../daemon-port/session-replay-divergence.ts | 2 +- .../__tests__/internal-observation.test.ts | 46 +++++++++----- ...sion-replay-divergence-observation.test.ts | 49 +++++++++++++++ src/daemon/handlers/session-replay-command.ts | 5 +- src/daemon/internal-observation.ts | 62 +++++++------------ 5 files changed, 106 insertions(+), 58 deletions(-) diff --git a/packages/replay-port/src/daemon-port/session-replay-divergence.ts b/packages/replay-port/src/daemon-port/session-replay-divergence.ts index f24caa1d61..6f8c01de76 100644 --- a/packages/replay-port/src/daemon-port/session-replay-divergence.ts +++ b/packages/replay-port/src/daemon-port/session-replay-divergence.ts @@ -304,6 +304,7 @@ async function captureDivergenceObservationAttempt(params: { }): Promise { const { session, observationStore, logPath, flags } = params; try { + const observationAuthority = observationStore.bindAuthority(); const capture = await observationStore.capture({ flags, logPath }); const snapshot = capture.snapshot; if (isSparseSnapshotQualityVerdict(snapshot.snapshotQuality)) { @@ -316,7 +317,6 @@ async function captureDivergenceObservationAttempt(params: { retryable: true, }; } - const observationAuthority = observationStore.bindAuthority(); const stored = observationAuthority.store(snapshot); return { observation: { diff --git a/src/daemon/__tests__/internal-observation.test.ts b/src/daemon/__tests__/internal-observation.test.ts index 29167cfafc..d798548777 100644 --- a/src/daemon/__tests__/internal-observation.test.ts +++ b/src/daemon/__tests__/internal-observation.test.ts @@ -42,11 +42,11 @@ function scenario() { const prior = snapshot('e1', 'Previously published'); setSessionSnapshot(session, prior); markSessionPartialRefsIssued(session, ['e1']); - sessionStore.set(sessionName, session); + const ref = sessionStore.publish(sessionName, session); const captured = snapshot('e2', 'Internal capture'); const authority = bindAuthority(sessionStore, sessionName); const stored = authority.store(captured); - return { sessionStore, sessionName, session, prior, captured, authority, ...stored }; + return { sessionStore, sessionName, ref, session, prior, captured, authority, ...stored }; } function publishCurrent(input: ReturnType, signal?: AbortSignal) { @@ -59,17 +59,8 @@ function publishCurrent(input: ReturnType, signal?: AbortSignal function bindAuthority(sessionStore: SessionStore, sessionName: string, signal?: AbortSignal) { return bindInternalObservationAuthority({ - sessionStore: { - get: () => sessionStore.get(sessionName), - update: (mutate) => { - const session = sessionStore.get(sessionName); - if (!session) return false; - mutate(session); - sessionStore.set(sessionName, session); - return true; - }, - }, - sessionName, + sessionStore, + ref: sessionStore.lookup(sessionName), ...(signal ? { signal } : {}), }); } @@ -173,12 +164,12 @@ test('runtime revision invalidates evidence even when the ref frame was already test('session close invalidates capture evidence', () => { const input = scenario(); - input.sessionStore.delete(input.sessionName); + input.sessionStore.retire(input.ref); expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' }); // Even restoring the exact same session object cannot revive evidence that // a stale finalization attempt already consumed. - input.sessionStore.set(input.sessionName, input.session); + input.sessionStore.publish(input.sessionName, input.session); expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' }); expect(refFrameScope(input.session)).toEqual(new Set(['e1'])); }); @@ -186,7 +177,8 @@ test('session close invalidates capture evidence', () => { test('same-name session replacement cannot inherit capture evidence', () => { const input = scenario(); const replacement = makeIosSession(input.sessionName, { appBundleId: 'com.example.app' }); - input.sessionStore.set(input.sessionName, replacement); + input.sessionStore.retire(input.ref); + input.sessionStore.publish(input.sessionName, replacement); expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' }); expect(refFrameTree(replacement)).toBeUndefined(); @@ -214,3 +206,25 @@ test('generation and ref projection must match the exact captured tree', () => { expect(refFrameScope(wrongGeneration.session)).toEqual(new Set(['e1'])); expect(refFrameScope(wrongRef.session)).toEqual(new Set(['e1'])); }); + +test('a same-lifetime rebuild preserves evidence and publishes into the latest record', () => { + const input = scenario(); + const current = input.sessionStore.update(input.ref, { appName: 'Rebuilt app' }); + expect(publishCurrent(input)).toEqual({ + published: true, + refsGeneration: input.refsGeneration, + refCount: 1, + }); + expect(current.appName).toBe('Rebuilt app'); + expect(refFrameState(current)).toBe('active'); + expect(refFrameScope(current)).toEqual(new Set(['e2'])); + expect(refFrameTree(current)).toBe(input.captured); +}); + +test('reusing the same record in a new lifetime cannot adopt unconsumed evidence', () => { + const input = scenario(); + input.sessionStore.retire(input.ref); + input.sessionStore.publish(input.sessionName, input.session); + expect(publishCurrent(input)).toEqual({ published: false, reason: 'stale-capture' }); + expect(refFrameScope(input.session)).toEqual(new Set(['e1'])); +}); diff --git a/src/daemon/__tests__/replay-divergence/session-replay-divergence-observation.test.ts b/src/daemon/__tests__/replay-divergence/session-replay-divergence-observation.test.ts index a5a4cfd878..2dd832ad7a 100644 --- a/src/daemon/__tests__/replay-divergence/session-replay-divergence-observation.test.ts +++ b/src/daemon/__tests__/replay-divergence/session-replay-divergence-observation.test.ts @@ -82,3 +82,52 @@ test('captureDivergenceObservation retryLaunchRace: the 12s deadline bounds retr vi.useRealTimers(); } }); + +test.each(['rebuild', 'retire'] as const)( + 'divergence capture binds observation authority before awaiting the native capture: %s', + async (change) => { + const root = mkdtempForTestSync('agent-device-divergence-lifetime-'); + const store = new SessionStore(path.join(root, 'sessions')); + const session = makeIosSession('default', { appBundleId: 'com.example.app' }); + const ref = store.publish('cwd:worktree:default', session); + const replay = replayDivergenceForTest(store, ref.address); + let captured!: (value: Record) => void; + let started!: () => void; + const capturing = new Promise((resolve) => { + started = resolve; + }); + mockDispatchCommand.mockImplementationOnce( + () => + new Promise((resolve) => { + captured = resolve; + started(); + }), + ); + const pending = captureDivergenceObservation({ + session: replay.session!, + observationStore: replay.observationStore, + logPath: path.join(root, 'daemon.log'), + action: { command: 'click', positionals: ['label="Save"'], flags: {} }, + }); + await capturing; + let current; + if (change === 'rebuild') current = store.update(ref, { appName: 'Latest app' }); + else { + store.retire(ref); + current = makeIosSession('default', { appBundleId: 'com.example.successor' }); + store.publish(ref.address, current); + } + captured({ nodes: [{ index: 0, depth: 0, type: 'Button', ref: 'e2', label: 'Save' }] }); + const result = await pending; + expect(store.get(ref.address)).toBe(current); + expect(store.get('default')).toBeUndefined(); + if (change === 'rebuild') { + expect(result.state).toBe('available'); + expect(current.appName).toBe('Latest app'); + expect(current.snapshot?.nodes[0]?.label).toBe('Save'); + } else { + expect(result.state).toBe('unavailable'); + expect(current.snapshot).toBeUndefined(); + } + }, +); diff --git a/src/daemon/handlers/session-replay-command.ts b/src/daemon/handlers/session-replay-command.ts index ce15cc7685..5701d577b9 100644 --- a/src/daemon/handlers/session-replay-command.ts +++ b/src/daemon/handlers/session-replay-command.ts @@ -112,7 +112,6 @@ export function createReplaySession( const session = store.get(name); if (!session) return false; mutate(session); - store.set(name, session); return true; }; // One read set for both views: the narrowed one the port binds over, and the full-record one the @@ -149,8 +148,8 @@ export function createReplaySession( }), bindAuthority: (signal) => bindInternalObservationAuthority({ - sessionStore: { get: () => store.get(name), update: updateSession }, - sessionName: name, + sessionStore: store, + ref: store.lookup(name), ...(signal ? { signal } : {}), }), capture: async ({ flags, logPath: captureLogPath }) => { diff --git a/src/daemon/internal-observation.ts b/src/daemon/internal-observation.ts index c616d7a61b..ad1e4bff5b 100644 --- a/src/daemon/internal-observation.ts +++ b/src/daemon/internal-observation.ts @@ -9,12 +9,11 @@ import type { import { readSessionRuntimeRevision, refFrame } from './ref-frame.ts'; import type { RefFrame } from './ref-frame-slot.ts'; import { markSessionPartialRefsIssued, setSessionSnapshot } from './session-snapshot.ts'; -import type { SessionState } from './session-state.ts'; +import type { SessionRef, SessionState } from './session-state.ts'; +import type { SessionStore } from './session-store.ts'; type InternalObservationLineage = Readonly<{ - sessionName: string; - session: SessionState; - sessionCreatedAt: number; + ref: SessionRef; snapshot: SnapshotState; snapshotGeneration: number; runtimeRevision: number; @@ -24,16 +23,11 @@ type InternalObservationLineage = Readonly<{ const evidenceLineage = new WeakMap(); type BoundInternalObservationSession = Readonly<{ - sessionStore: InternalObservationSessionStore; - sessionName: string; + sessionStore: SessionStore; + ref: SessionRef | undefined; signal?: AbortSignal; }>; -type InternalObservationSessionStore = Readonly<{ - get: () => SessionState | undefined; - update: (mutate: (session: SessionState) => void) => boolean; -}>; - /** * Bind observation authority to one already admitted, locked session. Callers * cannot address another session through the resulting capability, and engines @@ -58,21 +52,15 @@ export function bindInternalObservationAuthority( * without activating, replacing, or expiring client ref authority. */ function storeInternalObservation( - params: Pick, + params: Pick, snapshot: SnapshotState, ): ReplayObservationCapture { - const { sessionStore, sessionName } = params; - let storedSession: SessionState | undefined; - if ( - !sessionStore.update((session) => { - setSessionSnapshot(session, snapshot); - storedSession = session; - }) || - !storedSession - ) { + const { sessionStore, ref } = params; + if (!ref) { throw new Error('Internal observation session is no longer available.'); } - const session = storedSession; + const session = sessionStore.requireCurrent(ref); + setSessionSnapshot(session, snapshot); const snapshotGeneration = session.snapshotGeneration; if (snapshotGeneration === undefined) { throw new Error('Internal observation did not establish a snapshot generation.'); @@ -80,9 +68,7 @@ function storeInternalObservation( const evidence = {} as ReplayObservationEvidence; evidenceLineage.set(evidence, { - sessionName, - session, - sessionCreatedAt: session.createdAt, + ref, snapshot, snapshotGeneration, runtimeRevision: readSessionRuntimeRevision(session), @@ -100,8 +86,8 @@ function storeInternalObservation( * before the response returns to the client. */ function finalizeClientRefPublication(params: { - sessionStore: InternalObservationSessionStore; - sessionName: string; + sessionStore: SessionStore; + ref: SessionRef | undefined; evidence: ReplayObservationEvidence; projection: ReplayRefPublicationProjection; signal?: AbortSignal; @@ -115,7 +101,8 @@ function finalizeClientRefPublication(params: { if (refs.size === 0) return { published: false, reason: 'empty' }; if (params.signal?.aborted === true) return { published: false, reason: 'cancelled' }; - if (!lineage || !isCurrentLineage(params, lineage)) { + const session = lineage && resolveCurrentLineage(params, lineage); + if (!lineage || !session) { return { published: false, reason: 'stale-capture' }; } if ( @@ -125,7 +112,7 @@ function finalizeClientRefPublication(params: { return { published: false, reason: 'invalid-projection' }; } - markSessionPartialRefsIssued(lineage.session, refs); + markSessionPartialRefsIssued(session, refs); return { published: true, refsGeneration: lineage.snapshotGeneration, @@ -133,20 +120,19 @@ function finalizeClientRefPublication(params: { }; } -function isCurrentLineage( - params: Pick, +function resolveCurrentLineage( + params: Pick, lineage: InternalObservationLineage, -): boolean { - const current = params.sessionStore.get(); - return ( - params.sessionName === lineage.sessionName && - current === lineage.session && - current.createdAt === lineage.sessionCreatedAt && +): SessionState | undefined { + if (!params.ref || params.ref.lifetime !== lineage.ref.lifetime) return undefined; + const current = params.sessionStore.resolveCurrent(params.ref); + return current && current.snapshot === lineage.snapshot && current.snapshotGeneration === lineage.snapshotGeneration && readSessionRuntimeRevision(current) === lineage.runtimeRevision && refFrame(current) === lineage.refFrame - ); + ? current + : undefined; } function normalizeRefBodies(refs: readonly string[]): Set { From 0e24d484609f2cc6eda8bd9f8f5e6f960151ad5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 09:58:24 +0200 Subject: [PATCH 35/44] fix: retain one observation lifetime across replay capture retries --- .../src/daemon-port/command-types.ts | 12 ++-- .../src/daemon-port/replay-session-binding.ts | 5 +- .../daemon-port/session-replay-divergence.ts | 14 ++-- ...sion-replay-divergence-observation.test.ts | 65 ++++++++++++++++++- src/daemon/handlers/session-replay-command.ts | 35 +++++----- 5 files changed, 97 insertions(+), 34 deletions(-) diff --git a/packages/replay-port/src/daemon-port/command-types.ts b/packages/replay-port/src/daemon-port/command-types.ts index e34948b0f2..f9214bf21e 100644 --- a/packages/replay-port/src/daemon-port/command-types.ts +++ b/packages/replay-port/src/daemon-port/command-types.ts @@ -54,11 +54,13 @@ export type ReplaySessionStore = Readonly<{ */ export type ReplaySessionObservation = Readonly<{ get: () => ReplaySessionState | undefined; - bindAuthority: ReplayObservationAuthorityBinder; - capture: (params: { - flags: DaemonWireRequest['flags']; - logPath: string; - }) => Promise<{ snapshot: SnapshotState }>; + bindAuthority: (signal?: AbortSignal) => ReturnType & + Readonly<{ + capture: (params: { + flags: DaemonWireRequest['flags']; + logPath: string; + }) => Promise<{ snapshot: SnapshotState }>; + }>; }>; /** Immutable read projection of the repair-transaction fields the coordinator's writers touch. */ diff --git a/packages/replay-port/src/daemon-port/replay-session-binding.ts b/packages/replay-port/src/daemon-port/replay-session-binding.ts index d7f2d66108..51345c93fc 100644 --- a/packages/replay-port/src/daemon-port/replay-session-binding.ts +++ b/packages/replay-port/src/daemon-port/replay-session-binding.ts @@ -1,4 +1,3 @@ -import type { ReplayObservationAuthorityBinder } from '@agent-device/contracts/replay'; import type { ReplayCoordinator, ReplaySession, @@ -26,8 +25,7 @@ export type ReplaySessionPolicy = Readonly<{ createCoordinator: () => ReplayCoordinator; assertSelectorMatches: ReplaySessionStore['assertSelectorMatches']; resolveOpenRuntimeHints: ReplaySessionStore['resolveOpenRuntimeHints']; - bindAuthority: ReplayObservationAuthorityBinder; - capture: ReplaySessionObservation['capture']; + bindAuthority: ReplaySessionObservation['bindAuthority']; }>; /** @@ -53,7 +51,6 @@ export function bindReplaySession( observationStore: { get: container.get, bindAuthority: policy.bindAuthority, - capture: policy.capture, }, coordinator: policy.createCoordinator(), }; diff --git a/packages/replay-port/src/daemon-port/session-replay-divergence.ts b/packages/replay-port/src/daemon-port/session-replay-divergence.ts index 6f8c01de76..6551d8fd67 100644 --- a/packages/replay-port/src/daemon-port/session-replay-divergence.ts +++ b/packages/replay-port/src/daemon-port/session-replay-divergence.ts @@ -256,10 +256,11 @@ export async function captureDivergenceObservation(params: { // shrinks the retry budget rather than getting a free `DEADLINE_MS` on top // of however long it took. const deadline = Date.now() + DIVERGENCE_CAPTURE_RETRY_DEADLINE_MS; + const observation = observationStore.bindAuthority(); let attempt = await captureDivergenceObservationAttempt({ session, - observationStore, + observation, logPath, flags, }); @@ -272,7 +273,7 @@ export async function captureDivergenceObservation(params: { await sleep(Math.min(delayMs, remainingMs)); attempt = await captureDivergenceObservationAttempt({ session, - observationStore, + observation, logPath, flags, }); @@ -298,14 +299,13 @@ type DivergenceCaptureAttempt = { async function captureDivergenceObservationAttempt(params: { session: ReplaySessionState; - observationStore: ReplaySessionObservation; + observation: ReturnType; logPath: string; flags: CommandFlags; }): Promise { - const { session, observationStore, logPath, flags } = params; + const { session, observation, logPath, flags } = params; try { - const observationAuthority = observationStore.bindAuthority(); - const capture = await observationStore.capture({ flags, logPath }); + const capture = await observation.capture({ flags, logPath }); const snapshot = capture.snapshot; if (isSparseSnapshotQualityVerdict(snapshot.snapshotQuality)) { return { @@ -317,7 +317,7 @@ async function captureDivergenceObservationAttempt(params: { retryable: true, }; } - const stored = observationAuthority.store(snapshot); + const stored = observation.store(snapshot); return { observation: { state: 'available', diff --git a/src/daemon/__tests__/replay-divergence/session-replay-divergence-observation.test.ts b/src/daemon/__tests__/replay-divergence/session-replay-divergence-observation.test.ts index 2dd832ad7a..dc7e7edaac 100644 --- a/src/daemon/__tests__/replay-divergence/session-replay-divergence-observation.test.ts +++ b/src/daemon/__tests__/replay-divergence/session-replay-divergence-observation.test.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import { beforeEach, expect, test, vi } from 'vitest'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; import { AppError } from '@agent-device/kernel/errors'; +import { sleep } from '@agent-device/host-kit/retry'; import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; import { SessionStore } from '../../session-store.ts'; import { captureDivergenceObservation } from '@agent-device/replay-port/session-replay-divergence'; @@ -29,7 +30,10 @@ vi.mock('@agent-device/host-kit/retry', async (importOriginal) => { }); const mockDispatchCommand = legacyDispatchCapture; -beforeEach(() => resetLegacySnapshotCapture(vi.mocked(captureSnapshotWithInteractor))); +beforeEach(() => { + resetLegacySnapshotCapture(vi.mocked(captureSnapshotWithInteractor)); + vi.mocked(sleep).mockReset().mockResolvedValue(undefined); +}); // #1385 P2: the retry deadline is a DELAY-ONLY budget, not a per-attempt // capture timeout — this loop does not itself bound how long a single @@ -131,3 +135,62 @@ test.each(['rebuild', 'retire'] as const)( } }, ); + +test.each(['rebuild', 'retire'] as const)( + 'capture retries retain their original lifetime through backoff: %s', + async (change) => { + const root = mkdtempForTestSync('agent-device-divergence-retry-lifetime-'); + const store = new SessionStore(path.join(root, 'sessions')); + const session = makeIosSession('default', { appBundleId: 'com.example.app' }); + const ref = store.publish('cwd:worktree:default', session); + const replay = replayDivergenceForTest(store, ref.address); + let sleeping!: () => void; + let resume!: () => void; + const backoff = new Promise((resolve) => { + sleeping = resolve; + }); + vi.mocked(sleep).mockImplementationOnce( + () => + new Promise((resolve) => { + resume = resolve; + sleeping(); + }), + ); + mockDispatchCommand + .mockResolvedValueOnce({ + nodes: [], + quality: { state: 'sparse', backend: 'tree' }, + }) + .mockResolvedValueOnce({ + nodes: [{ index: 0, depth: 0, type: 'Button', ref: 'e2', label: 'Save' }], + }); + const pending = captureDivergenceObservation({ + session: replay.session!, + observationStore: replay.observationStore, + logPath: path.join(root, 'daemon.log'), + retryLaunchRace: true, + action: { command: 'click', positionals: ['label="Save"'], flags: {} }, + }); + await backoff; + let current; + if (change === 'rebuild') current = store.update(ref, { appName: 'Latest app' }); + else { + store.retire(ref); + current = makeIosSession('default', { appBundleId: 'com.example.successor' }); + store.publish(ref.address, current); + } + resume(); + const result = await pending; + expect(store.get(ref.address)).toBe(current); + if (change === 'rebuild') { + expect(mockDispatchCommand).toHaveBeenCalledTimes(2); + expect(result.state).toBe('available'); + expect(current.appName).toBe('Latest app'); + expect(current.snapshot?.nodes[0]?.label).toBe('Save'); + } else { + expect(mockDispatchCommand).toHaveBeenCalledTimes(1); + expect(result.state).toBe('unavailable'); + expect(current.snapshot).toBeUndefined(); + } + }, +); diff --git a/src/daemon/handlers/session-replay-command.ts b/src/daemon/handlers/session-replay-command.ts index 5701d577b9..29b3321094 100644 --- a/src/daemon/handlers/session-replay-command.ts +++ b/src/daemon/handlers/session-replay-command.ts @@ -146,23 +146,24 @@ export function createReplaySession( device, platform, }), - bindAuthority: (signal) => - bindInternalObservationAuthority({ - sessionStore: store, - ref: store.lookup(name), - ...(signal ? { signal } : {}), - }), - capture: async ({ flags, logPath: captureLogPath }) => { - const session = store.get(name); - if (!session) { - throw new AppError('NO_ACTIVE_SESSION', `Session "${name}" is no longer active.`); - } - return await captureSnapshot({ - device: session.device, - session, - flags, - logPath: captureLogPath, - }); + bindAuthority: (signal) => { + const ref = store.lookup(name); + return { + ...bindInternalObservationAuthority({ sessionStore: store, ref, signal }), + capture: async ({ flags, logPath: captureLogPath }) => { + const session = ref ? store.requireCurrent(ref) : undefined; + if (!session) { + throw new AppError('NO_ACTIVE_SESSION', `Session "${name}" is no longer active.`); + } + return await captureSnapshot({ + device: session.device, + session, + flags, + logPath: captureLogPath, + signal, + }); + }, + }; }, }); } From f8b5f93e91f6bb3a6279ad7e8436d854f1a93b1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 10:16:39 +0200 Subject: [PATCH 36/44] fix: publish open results into their admitted session lifetime --- .../session-open-execution-runtime.test.ts | 161 ++++++++++++++++++ .../__tests__/session-open-state.test.ts | 58 +++++++ .../__tests__/session-open-surface.test.ts | 50 +----- .../internal/session-open-execution.ts | 113 +++++------- .../internal/session-open-state.ts | 71 ++++++++ .../internal/session-open-surface.ts | 37 +--- .../internal/session-open.ts | 29 +++- src/daemon/session-snapshot.ts | 4 + 8 files changed, 365 insertions(+), 158 deletions(-) create mode 100644 src/daemon/session-lifecycle/internal/__tests__/session-open-state.test.ts create mode 100644 src/daemon/session-lifecycle/internal/session-open-state.ts diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts index 095d95cf90..0e00b55ce8 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts @@ -6,6 +6,11 @@ import type { OpenApplicationInput, } from '@agent-device/contracts/application-lifecycle-runtime'; import type { DaemonRequest } from '../../../daemon-request.ts'; +import { + registerRequestAbort, + markRequestCanceled, + clearRequestAbortRegistration, +} from '@agent-device/host-kit/request'; import { AppError } from '@agent-device/kernel/errors'; const mockResolveTargetDevice = vi.hoisted(() => vi.fn()); @@ -436,3 +441,159 @@ test('open reports the launch confirmation its platform answered', async () => { expect(response?.ok).toBe(true); if (response?.ok) expect(response.data?.launchConfirmation).toBe('accepted'); }); + +function holdNativeOpen() { + let entered!: () => void; + let release!: () => void; + const reached = new Promise((resolve) => { + entered = resolve; + }); + const held = new Promise((resolve) => { + release = resolve; + }); + const bindDefault = mockBindDeviceRuntime.getMockImplementation(); + if (!bindDefault) throw new Error('the harness binds a default runtime'); + mockBindDeviceRuntime.mockImplementationOnce(async (device, use) => { + const binding = await bindDefault(device, use); + const operations = binding.operations as ApplicationLifecycleRuntimeOperations; + return { + ...binding, + operations: { + ...binding.operations, + openApplication: async (input: OpenApplicationInput) => { + entered(); + await held; + return await operations.openApplication(input); + }, + }, + }; + }); + return { reached, release }; +} + +function invokeHeldOpen( + sessionStore: ReturnType, + req?: Partial, +) { + return handleSessionCommands({ + req: { + token: 't', + command: 'open', + session: 'default', + positionals: ['com.example.demo'], + flags: { platform: 'android' }, + ...req, + }, + sessionName: 'cwd:held-open:default', + sessionStore, + logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), + invoke: noopInvoke, + }); +} + +test('reopen publishes into the latest record of the same lifetime after native launch', async () => { + const store = makeSessionStore(); + const session = makeSession('default', makeAndroidEmulator()); + const ref = store.publish('cwd:held-open:default', session); + const native = holdNativeOpen(); + const pending = invokeHeldOpen(store); + await native.reached; + store.update(ref, { recordOnlySession: true }); + native.release(); + expect((await pending)?.ok).toBe(true); + expect(store.lookup(ref.address)?.lifetime).toBe(ref.lifetime); + expect(store.requireCurrent(ref).recordOnlySession).toBe(true); + expect(store.requireCurrent(ref).createdAt).toBe(session.createdAt); + expect(store.requireCurrent(ref).name).toBe('default'); +}); + +test('a retired reopen cannot write hints or actions to the same record republished as a successor', async () => { + const store = makeSessionStore(); + const session = makeSession('default', makeAndroidEmulator()); + const ref = store.publish('cwd:held-open:default', session); + const native = holdNativeOpen(); + const pending = invokeHeldOpen(store, { runtime: { metroHost: 'new-host', metroPort: 9000 } }); + const refusal = expect(pending).rejects.toThrow( + expect.objectContaining({ + details: expect.objectContaining({ reason: 'session_lifetime_ended' }), + }), + ); + await native.reached; + store.retire(ref); + const successor = store.publish(ref.address, session); + store.setRuntimeHints(ref.address, { platform: 'android', metroHost: 'successor-host' }); + native.release(); + await refusal; + expect(store.requireCurrent(successor)).toBe(session); + expect(session.actions).toEqual([]); + expect(store.getRuntimeHints(ref.address)?.metroHost).toBe('successor-host'); +}); + +test('a provisional open refuses a successor before native launch', async () => { + const store = makeSessionStore(); + const session = makeSession('default', makeAndroidEmulator()); + const ref = store.publish('cwd:held-open:default', session); + await expect( + invokeHeldOpen(store, { + internal: { + openLifecycle: { + beforeDispatch: async () => { + const provisional = store.requireCurrent(ref); + store.retire(ref); + store.publish(ref.address, provisional); + }, + }, + }, + }), + ).rejects.toThrow( + expect.objectContaining({ + details: expect.objectContaining({ reason: 'session_lifetime_ended' }), + }), + ); + expect(mockDispatch).not.toHaveBeenCalled(); + expect(store.get(ref.address)?.actions).toEqual([]); +}); + +test('fresh open completing after shutdown cannot publish or replace runtime hints', async () => { + const store = makeSessionStore(); + mockResolveTargetDevice.mockResolvedValue(makeAndroidEmulator('emulator-shutdown-open')); + const native = holdNativeOpen(); + const pending = invokeHeldOpen(store, { runtime: { metroHost: 'new-host', metroPort: 9000 } }); + const refusal = expect(pending).rejects.toThrow( + expect.objectContaining({ + details: expect.objectContaining({ reason: 'daemon_shutting_down' }), + }), + ); + await native.reached; + store.closeAdmission(); + native.release(); + await refusal; + expect(store.get('cwd:held-open:default')).toBeUndefined(); + expect(store.getRuntimeHints('cwd:held-open:default')).toBeUndefined(); +}); + +test('cancelled fresh open does not publish after native launch returns', async () => { + const store = makeSessionStore(); + mockResolveTargetDevice.mockResolvedValue(makeAndroidEmulator('emulator-cancelled-open')); + const native = holdNativeOpen(); + const requestId = 'cancelled-open-lifetime'; + const registration = registerRequestAbort(requestId); + try { + const pending = invokeHeldOpen(store, { meta: { requestId } }); + await native.reached; + markRequestCanceled(requestId); + native.release(); + expect(await pending).toEqual( + expect.objectContaining({ + ok: false, + error: expect.objectContaining({ + code: 'COMMAND_FAILED', + details: expect.objectContaining({ reason: 'request_canceled' }), + }), + }), + ); + expect(store.get('cwd:held-open:default')).toBeUndefined(); + } finally { + clearRequestAbortRegistration(registration); + } +}); diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-state.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-state.test.ts new file mode 100644 index 0000000000..cd48fa95c5 --- /dev/null +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-state.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { publishOpenSession } from '../session-open-state.ts'; +import { makeSessionStore } from './session-open-runtime.fixtures.ts'; +import { + authoringPublication, + makeIosSession, +} from '../../../../__tests__/test-utils/session-factories.ts'; +import { IOS_SIMULATOR } from '../../../../__tests__/test-utils/device-fixtures.ts'; +import { isSessionRecording } from '../../../session-script-publication-capability.ts'; + +// --- #1533: a re-open cannot resurrect a terminal authoring lifecycle --- +// +// This surface used to decide recording on its own (`existingSession.recordSession || saveScript`), +// which is how an ABORTED lifecycle came back to life on a third `open --save-script`. Recording is +// now derived from the lifecycle, so the only thing left to pin here is that a re-open carries the +// publication state through untouched — it has no arming decision to get wrong. + +function reopen(existingSession: ReturnType) { + const store = makeSessionStore(); + const ref = store.publish(existingSession.name, existingSession); + return store.requireCurrent( + publishOpenSession({ + req: { token: 't', command: 'open', positionals: [], session: existingSession.name }, + sessionStore: store, + sessionName: existingSession.name, + existingRef: ref, + device: IOS_SIMULATOR, + surface: 'app', + appBundleId: 'com.example.other', + }), + ); +} + +test('#1533: a re-open leaves an aborted authoring lifecycle aborted, and not recording', () => { + const aborted = makeIosSession('s', { scriptPublication: authoringPublication('aborted') }); + + const next = reopen(aborted); + + assert.deepEqual(next.scriptPublication, authoringPublication('aborted')); + assert.equal(isSessionRecording(next), false); +}); + +test('a re-open carries an armed authoring lifecycle through unchanged', () => { + const armed = makeIosSession('s', { scriptPublication: authoringPublication('armed') }); + + const next = reopen(armed); + + assert.deepEqual(next.scriptPublication, authoringPublication('armed')); + assert.equal(isSessionRecording(next), true); +}); + +test('a re-open of a session that never armed records nothing', () => { + const next = reopen(makeIosSession('s')); + + assert.equal(next.scriptPublication, undefined); + assert.equal(isSessionRecording(next), false); +}); diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-surface.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-surface.test.ts index 63d81782cb..12cb6b9f15 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-surface.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-surface.test.ts @@ -1,14 +1,9 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; -import { buildNextOpenSession, buildOpenResult } from '../session-open-surface.ts'; +import { buildOpenResult } from '../session-open-surface.ts'; import { resolveRequestedOpenSurface } from '../../../../platform-runtime-open-target.ts'; -import { - authoringPublication, - makeIosSession, -} from '../../../../__tests__/test-utils/session-factories.ts'; import { IOS_SIMULATOR } from '../../../../__tests__/test-utils/device-fixtures.ts'; -import { isSessionRecording } from '../../../session-script-publication-capability.ts'; test('resolveRequestedOpenSurface rejects surface flag on iOS', () => { assert.throws( @@ -103,46 +98,3 @@ test('buildOpenResult reports an answered launch confirmation and omits one that ); assert.equal('launchConfirmation' in buildOpenResult(base), false); }); - -// --- #1533: a re-open cannot resurrect a terminal authoring lifecycle --- -// -// This surface used to decide recording on its own (`existingSession.recordSession || saveScript`), -// which is how an ABORTED lifecycle came back to life on a third `open --save-script`. Recording is -// now derived from the lifecycle, so the only thing left to pin here is that a re-open carries the -// publication state through untouched — it has no arming decision to get wrong. - -function reopen(existingSession: ReturnType) { - return buildNextOpenSession({ - existingSession, - sessionName: existingSession.name, - sessionScope: existingSession.sessionScope ?? { kind: 'named-local' }, - device: IOS_SIMULATOR, - surface: 'app', - appBundleId: 'com.example.other', - }); -} - -test('#1533: a re-open leaves an aborted authoring lifecycle aborted, and not recording', () => { - const aborted = makeIosSession('s', { scriptPublication: authoringPublication('aborted') }); - - const next = reopen(aborted); - - assert.deepEqual(next.scriptPublication, authoringPublication('aborted')); - assert.equal(isSessionRecording(next), false); -}); - -test('a re-open carries an armed authoring lifecycle through unchanged', () => { - const armed = makeIosSession('s', { scriptPublication: authoringPublication('armed') }); - - const next = reopen(armed); - - assert.deepEqual(next.scriptPublication, authoringPublication('armed')); - assert.equal(isSessionRecording(next), true); -}); - -test('a re-open of a session that never armed records nothing', () => { - const next = reopen(makeIosSession('s')); - - assert.equal(next.scriptPublication, undefined); - assert.equal(isSessionRecording(next), false); -}); diff --git a/src/daemon/session-lifecycle/internal/session-open-execution.ts b/src/daemon/session-lifecycle/internal/session-open-execution.ts index 82d04c6364..49d2e89d5b 100644 --- a/src/daemon/session-lifecycle/internal/session-open-execution.ts +++ b/src/daemon/session-lifecycle/internal/session-open-execution.ts @@ -8,10 +8,10 @@ import { type DeviceSelectionResult, } from '@agent-device/device-selection/device-selection-resolver'; import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtime'; -import type { SessionScope, SessionSurface } from '@agent-device/contracts/session'; +import type { SessionSurface } from '@agent-device/contracts/session'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { DaemonRequest, DaemonResponse } from '../../daemon-request.ts'; -import type { SessionState } from '../../session-state.ts'; +import type { SessionRef, SessionState } from '../../session-state.ts'; import { abortAuthoringOnSecondOpen, armAuthoringOnOpen, @@ -30,7 +30,8 @@ import { setSessionRuntimeHintsForOpen, } from '../../session-runtime.ts'; import { STARTUP_SAMPLE_METHOD, type StartupPerfSample } from './session-startup-metrics.ts'; -import { buildNextOpenSession, buildOpenResult } from './session-open-surface.ts'; +import { buildOpenResult } from './session-open-surface.ts'; +import { publishOpenSession, requireOpenSessionAdmission } from './session-open-state.ts'; import { markDeferredInteractionOutcome } from '../../deferred-interaction-outcome.ts'; import { emitDiagnostic, getDiagnosticsMeta } from '@agent-device/host-kit/diagnostics'; import { @@ -42,12 +43,7 @@ import { buildForeignWorkspaceSessionConflict, } from '../../session-recovery-hints.ts'; import { describeOpenWaitForRefusal } from '../../open-device-contention-wait.ts'; -import { - isImplicitSessionScopeConflict, - resolveSessionScope, - resolvePublicSessionName, -} from '../../session-routing.ts'; -import { resolveSessionLeaseForRequest } from '../../lease-lifecycle.ts'; +import { isImplicitSessionScopeConflict } from '../../session-routing.ts'; import { applicationLifecycleExecutionFromRequest } from '../../application-lifecycle-execution.ts'; import { abandonDeviceClaim, @@ -92,10 +88,6 @@ export type RuntimeHintClearOperation = BoundDeviceRuntime< typeof openApplicationWithRuntimeHintClearUse >['operations']['clearRuntimeHints']; -function resolveOpenSessionScope(req: DaemonRequest): SessionScope { - return req.internal?.resolvedSessionScope ?? resolveSessionScope(req); -} - function applyOrdinaryScriptRecordingOpenOutcome(params: { session: SessionState; existingSession: SessionState | undefined; @@ -188,7 +180,7 @@ export async function completeOpenCommand(params: { runtimeHints: ReturnType; lifecycle: OpenApplicationRuntime; applyRuntimeHints?: RuntimeHintApplyOperation; - existingSession?: SessionState; + existingRef?: SessionRef; deviceClaim?: DeviceClaimSessionOwnership; /** The stale claim this open released before taking the device, when there was one. */ tookOverDeviceClaim?: TakenOverDeviceClaim; @@ -208,11 +200,14 @@ export async function completeOpenCommand(params: { runtimeHints, lifecycle, applyRuntimeHints, - existingSession, + existingRef, deviceClaim, tookOverDeviceClaim, selection, } = params; + requireOpenSessionAdmission(sessionStore, sessionName, existingRef); + const existingSession = existingRef ? sessionStore.requireCurrent(existingRef) : undefined; + const freshnessBaseline = existingSession?.snapshot; const shouldRelaunch = req.flags?.relaunch === true; let sessionAppBundleId = appBundleId; const openCommandStartedAtMs = Date.now(); @@ -225,10 +220,14 @@ export async function completeOpenCommand(params: { surface, sessionAppBundleId, appName, - existingSession, + existingRef, }); if (provisionalSession.type === 'response') return provisionalSession.response; - const openDispatchSession = provisionalSession.session ?? existingSession; + const openDispatchRef = provisionalSession.ref; + requireOpenSessionAdmission(sessionStore, sessionName, openDispatchRef); + const openDispatchSession = openDispatchRef + ? sessionStore.requireCurrent(openDispatchRef) + : undefined; const openStartedAtMs = Date.now(); const outcome = await lifecycle.operations.openApplication({ target: openTarget, @@ -247,7 +246,7 @@ export async function completeOpenCommand(params: { execution: applicationLifecycleExecutionFromRequest( req, logPath, - existingSession?.trace?.outPath, + openDispatchSession?.trace?.outPath, ), }); sessionAppBundleId = outcome.appBundleId ?? sessionAppBundleId; @@ -261,30 +260,28 @@ export async function completeOpenCommand(params: { return errorResponse(canceled.code, canceled.message, canceled.details); } - if (existingSession) { - // Mark before buildNextOpenSession clears the stored snapshot. `open` is one of the few - // nav-sensitive commands that would otherwise lose its pre-action freshness baseline. + requireOpenSessionAdmission(sessionStore, sessionName, openDispatchRef); + if (existingRef) { markDeferredInteractionOutcome({ - session: existingSession, + session: sessionStore.requireCurrent(existingRef), command: 'open', positionals: [], flags: undefined, + androidFreshnessBaseline: freshnessBaseline, }); } - const nextSession = buildNextOpenSession({ - existingSession: openDispatchSession, - sessionName: existingSession?.name ?? resolvePublicSessionName(req), - sessionScope: existingSession?.sessionScope ?? resolveOpenSessionScope(req), + const nextRef = publishOpenSession({ + req, + sessionStore, + sessionName, + existingRef: openDispatchRef, device, surface, appBundleId: sessionAppBundleId, appName, + deviceClaim, }); - nextSession.lease = resolveSessionLeaseForRequest({ - req, - existingLease: existingSession?.lease, - }); - if (deviceClaim) nextSession.deviceClaim = deviceClaim; + const nextSession = sessionStore.requireCurrent(nextRef); if (req.runtime !== undefined) setSessionRuntimeHintsForOpen(sessionStore, sessionName, runtimeHints); const sessionStateDir = sessionStore.ensureSessionDir(sessionName); @@ -322,11 +319,10 @@ export async function completeOpenCommand(params: { } applyOrdinaryScriptRecordingOpenOutcome({ session: nextSession, - existingSession, + existingSession: existingRef ? nextSession : undefined, saveScriptRequested: Boolean(req.flags?.saveScript), responseData: openResult, }); - sessionStore.set(sessionName, nextSession); sessionStore.recordAction(nextSession, { command: 'open', positionals: openPositionals, @@ -345,45 +341,28 @@ async function prepareOpenDispatchSession(params: { surface: SessionSurface; sessionAppBundleId: string | undefined; appName: string | undefined; - existingSession: SessionState | undefined; + existingRef: SessionRef | undefined; }): Promise< - { type: 'session'; session?: SessionState } | { type: 'response'; response: DaemonResponse } + { type: 'session'; ref?: SessionRef } | { type: 'response'; response: DaemonResponse } > { - const { req, sessionName, sessionStore, existingSession } = params; + const { req, sessionName, sessionStore, existingRef } = params; const beforeDispatch = req.internal?.openLifecycle?.beforeDispatch; - if (!beforeDispatch) return { type: 'session', session: existingSession }; - const provisionalSession = createProvisionalOpenDispatchSession(params); - sessionStore.set(sessionName, provisionalSession); + if (!beforeDispatch) return { type: 'session', ref: existingRef }; + const ref = publishOpenSession({ + req, + sessionName, + sessionStore, + existingRef, + device: params.device, + surface: params.surface, + appBundleId: params.sessionAppBundleId, + appName: params.appName, + }); const lifecycleResponse = await beforeDispatch(); if (lifecycleResponse && !lifecycleResponse.ok) return { type: 'response', response: lifecycleResponse }; - return { type: 'session', session: sessionStore.get(sessionName) ?? provisionalSession }; -} - -function createProvisionalOpenDispatchSession(params: { - req: DaemonRequest; - sessionName: string; - device: DeviceInfo; - surface: SessionSurface; - sessionAppBundleId: string | undefined; - appName: string | undefined; - existingSession: SessionState | undefined; -}): SessionState { - const { req, device, surface, sessionAppBundleId, appName, existingSession } = params; - const provisionalSession = buildNextOpenSession({ - existingSession, - sessionName: existingSession?.name ?? resolvePublicSessionName(req), - sessionScope: existingSession?.sessionScope ?? resolveOpenSessionScope(req), - device, - surface, - appBundleId: sessionAppBundleId, - appName, - }); - provisionalSession.lease = resolveSessionLeaseForRequest({ - req, - existingLease: existingSession?.lease, - }); - return provisionalSession; + requireOpenSessionAdmission(sessionStore, sessionName, ref); + return { type: 'session', ref: sessionStore.refresh(ref) }; } /** @@ -482,6 +461,7 @@ export async function openNewSessionWithDeviceClaim(params: { reconcileOrphanedDeviceClaim, selection, } = params; + requireOpenSessionAdmission(sessionStore, sessionName, undefined); const conflict = findNewSessionDeviceConflict({ req, device, sessionStore }); if (conflict) return conflict; @@ -507,6 +487,7 @@ export async function openNewSessionWithDeviceClaim(params: { sessionStore, }); try { + requireOpenSessionAdmission(sessionStore, sessionName, undefined); const details = await prepareOpenCommandDetails({ req, logPath, diff --git a/src/daemon/session-lifecycle/internal/session-open-state.ts b/src/daemon/session-lifecycle/internal/session-open-state.ts new file mode 100644 index 0000000000..828c6c564d --- /dev/null +++ b/src/daemon/session-lifecycle/internal/session-open-state.ts @@ -0,0 +1,71 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { SessionSurface } from '@agent-device/contracts/session'; +import type { DaemonRequest } from '../../daemon-request.ts'; +import type { SessionRef } from '../../session-state.ts'; +import type { SessionStore } from '../../session-store.ts'; +import type { DeviceClaimSessionOwnership } from '../../device/device-claims.ts'; +import { clearSessionSnapshot } from '../../session-snapshot.ts'; +import { resolveSessionLeaseForRequest } from '../../lease-lifecycle.ts'; +import { resolvePublicSessionName, resolveSessionScope } from '../../session-routing.ts'; + +export function requireOpenSessionAdmission( + store: SessionStore, + address: string, + ref: SessionRef | undefined, +): void { + if (ref) { + store.assertAdmissionOpen(ref.address); + store.requireCurrent(ref); + } else { + store.assertPublishable(address); + } +} + +export function publishOpenSession(params: { + req: DaemonRequest; + sessionStore: SessionStore; + sessionName: string; + existingRef?: SessionRef; + device: DeviceInfo; + surface: SessionSurface; + appBundleId?: string; + appName?: string; + deviceClaim?: DeviceClaimSessionOwnership; +}): SessionRef { + const { + req, + sessionStore: store, + sessionName, + existingRef, + device, + surface, + appBundleId, + appName, + deviceClaim, + } = params; + requireOpenSessionAdmission(store, sessionName, existingRef); + if (existingRef) { + const session = store.update(existingRef, { + device, + surface, + appBundleId, + appName, + }); + session.lease = resolveSessionLeaseForRequest({ req, existingLease: session.lease }); + if (deviceClaim) session.deviceClaim = deviceClaim; + clearSessionSnapshot(session); + return store.refresh(existingRef); + } + return store.publish(sessionName, { + name: resolvePublicSessionName(req), + sessionScope: req.internal?.resolvedSessionScope ?? resolveSessionScope(req), + device, + surface, + appBundleId, + appName, + createdAt: Date.now(), + actions: [], + lease: resolveSessionLeaseForRequest({ req }), + deviceClaim, + }); +} diff --git a/src/daemon/session-lifecycle/internal/session-open-surface.ts b/src/daemon/session-lifecycle/internal/session-open-surface.ts index 82befde932..daa220a92e 100644 --- a/src/daemon/session-lifecycle/internal/session-open-surface.ts +++ b/src/daemon/session-lifecycle/internal/session-open-surface.ts @@ -1,12 +1,12 @@ import type { LaunchConfirmation } from '@agent-device/contracts/application-lifecycle-runtime'; -import type { SessionScope, SessionSurface } from '@agent-device/contracts/session'; +import type { SessionSurface } from '@agent-device/contracts/session'; import { isIosFamily, isSerialAddressablePlatform, publicPlatformString, type DeviceInfo, } from '@agent-device/kernel/device'; -import type { SessionRuntimeHints, SessionState } from '../../session-state.ts'; +import type { SessionRuntimeHints } from '../../session-state.ts'; import { successText } from '@agent-device/kernel/success-text'; import type { StartupPerfSample } from './session-startup-metrics.ts'; import type { DeviceSelectionResult } from '@agent-device/device-selection/device-selection-resolver'; @@ -90,36 +90,3 @@ function selectionResponseData( const { device: _device, ...metadata } = selection; return { selection: metadata }; } - -export function buildNextOpenSession(params: { - existingSession?: SessionState; - sessionName: string; - sessionScope: SessionScope; - device: DeviceInfo; - surface: SessionSurface; - appBundleId?: string; - appName?: string; -}): SessionState { - const { existingSession, sessionName, sessionScope, device, surface, appBundleId, appName } = - params; - if (existingSession) { - return { - ...existingSession, - device, - surface, - appBundleId, - appName, - snapshot: undefined, - }; - } - return { - name: sessionName, - sessionScope, - device, - createdAt: Date.now(), - surface, - appBundleId, - appName, - actions: [], - }; -} diff --git a/src/daemon/session-lifecycle/internal/session-open.ts b/src/daemon/session-lifecycle/internal/session-open.ts index 098cb7f46d..7939036390 100644 --- a/src/daemon/session-lifecycle/internal/session-open.ts +++ b/src/daemon/session-lifecycle/internal/session-open.ts @@ -43,6 +43,7 @@ import { type RuntimeHintApplyOperation, type RuntimeHintClearOperation, } from './session-open-execution.ts'; +import { requireOpenSessionAdmission } from './session-open-state.ts'; import { errorResponse } from '@agent-device/kernel/contracts'; export type SessionOpenCommandInput = Readonly<{ @@ -152,15 +153,17 @@ async function resolveOpenRuntimePlanAdmission(params: { async function handleOpenCommand(params: SessionOpenCommandInput): Promise { const { sessionName, logPath, sessionStore } = params; - const session = sessionStore.get(sessionName); + const existingRef = sessionStore.lookup(sessionName); const foregroundResolution = await resolveForegroundOpenRequest({ req: params.req, - hasExistingSession: Boolean(session), + hasExistingSession: Boolean(existingRef), }); if (foregroundResolution.type === 'response') return foregroundResolution.response; const req = foregroundResolution.type === 'resolved' ? foregroundResolution.req : params.req; - if (session) { + requireOpenSessionAdmission(sessionStore, sessionName, existingRef); + if (existingRef) { + const session = sessionStore.requireCurrent(existingRef); if (req.flags?.saveScript) { return errorResponse( 'INVALID_ARGS', @@ -193,7 +196,11 @@ async function handleOpenCommand(params: SessionOpenCommandInput): Promise Date: Sat, 3 Oct 2026 10:34:15 +0200 Subject: [PATCH 37/44] fix: carry the opened lifetime through foreground composition --- .../session-open-execution-runtime.test.ts | 53 +++++++++++++++ .../__tests__/session-open-foreground.test.ts | 31 +++++---- .../internal/session-open-execution.ts | 64 +++++++++++-------- .../internal/session-open-foreground.ts | 8 ++- .../internal/session-open.ts | 59 ++++++++++------- 5 files changed, 152 insertions(+), 63 deletions(-) diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts index 0e00b55ce8..b5f11e6f11 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts @@ -1,6 +1,7 @@ import { test, expect, vi, beforeEach } from 'vitest'; import path from 'node:path'; +import fs from 'node:fs'; import type { ApplicationLifecycleRuntimeOperations, OpenApplicationInput, @@ -597,3 +598,55 @@ test('cancelled fresh open does not publish after native launch returns', async clearRequestAbortRegistration(registration); } }); + +test('fresh open does not publish when its artifact directory cannot be created', async () => { + const store = makeSessionStore(); + mockResolveTargetDevice.mockResolvedValue(makeAndroidEmulator('emulator-directory-failed-open')); + fs.writeFileSync(path.dirname(store.resolveSessionDir('cwd:held-open:default')), 'blocked'); + await expect( + invokeHeldOpen(store, { runtime: { metroHost: 'new-host', metroPort: 9000 } }), + ).rejects.toThrow(); + expect(mockDispatch).toHaveBeenCalled(); + expect(store.get('cwd:held-open:default')).toBeUndefined(); + expect(store.getRuntimeHints('cwd:held-open:default')).toBeUndefined(); +}); + +for (const transition of ['rebuild', 'retire'] as const) { + test(`foreground composition retains the published lifetime across ${transition}`, async () => { + const store = makeSessionStore(); + mockResolveTargetDevice.mockResolvedValue( + makeAndroidEmulator(`emulator-foreground-${transition}`), + ); + const record = store.recordAction.bind(store); + vi.spyOn(store, 'recordAction').mockImplementationOnce((session, entry) => { + record(session, entry); + const ref = store.lookup('cwd:held-open:default')!; + queueMicrotask(() => { + if (transition === 'rebuild') { + store.update(ref, { recordOnlySession: true }); + } else { + store.retire(ref); + store.publish(ref.address, session); + } + mockInspectDeviceRuntimeFacts.mockClear(); + }); + }); + const response = await invokeHeldOpen(store, { + flags: { platform: 'android', foreground: true }, + }); + expect(response?.ok).toBe(true); + if (!response?.ok) throw new Error('open must remain successful'); + if (transition === 'retire') { + expect(response.data?.initialSnapshotError).toEqual( + expect.objectContaining({ + details: expect.objectContaining({ reason: 'session_lifetime_ended' }), + }), + ); + expect(mockInspectDeviceRuntimeFacts).not.toHaveBeenCalled(); + expect(store.get('cwd:held-open:default')?.snapshot).toBeUndefined(); + } else { + expect(mockInspectDeviceRuntimeFacts).toHaveBeenCalled(); + expect(store.get('cwd:held-open:default')?.recordOnlySession).toBe(true); + } + }); +} diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-foreground.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-foreground.test.ts index a2039a45f6..3274cbb9ee 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-foreground.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-foreground.test.ts @@ -4,6 +4,11 @@ const dispatchSnapshotViaRuntime = vi.hoisted(() => vi.fn()); vi.mock('../../../snapshot-runtime.ts', () => ({ dispatchSnapshotViaRuntime })); +import { + makeSessionStore, + makeSession, + makeAndroidEmulator, +} from './session-open-runtime.fixtures.ts'; import { AppError } from '@agent-device/kernel/errors'; import type { DaemonRequest, DaemonResponse } from '../../../daemon-request.ts'; import { @@ -13,6 +18,8 @@ import { const inspectFacts = vi.fn(); const bindDevice = vi.fn(); +let sessionStore: ReturnType; +let ref: ReturnType; function baseRequest(overrides: Partial = {}): DaemonRequest { return { @@ -26,6 +33,8 @@ function baseRequest(overrides: Partial = {}): DaemonRequest { } beforeEach(() => { + sessionStore = makeSessionStore(); + ref = sessionStore.publish('default', makeSession('default', makeAndroidEmulator())); dispatchSnapshotViaRuntime.mockReset(); inspectFacts.mockReset(); bindDevice.mockReset(); @@ -142,9 +151,9 @@ const failedOpenResponse: DaemonResponse = { test('passes a failed open response through untouched', async () => { const result = await composeOpenWithInitialSnapshot({ req: baseRequest({ flags: { foreground: true } }), - sessionName: 'default', + ref, logPath: '/tmp/daemon.log', - sessionStore: {} as never, + sessionStore, openResponse: failedOpenResponse, inspectFacts, bindDevice, @@ -157,9 +166,9 @@ test('passes a failed open response through untouched', async () => { test('leaves a successful open response untouched when --foreground was not requested', async () => { const result = await composeOpenWithInitialSnapshot({ req: baseRequest(), - sessionName: 'default', + ref, logPath: '/tmp/daemon.log', - sessionStore: {} as never, + sessionStore, openResponse: okOpenResponse, inspectFacts, bindDevice, @@ -175,9 +184,9 @@ test('attaches the initial INTERACTIVE snapshot by delegating to the existing sn const req = baseRequest({ flags: { foreground: true }, positionals: ['xyz.blueskyweb.app'] }); const result = await composeOpenWithInitialSnapshot({ req, - sessionName: 'default', + ref, logPath: '/tmp/daemon.log', - sessionStore: {} as never, + sessionStore, openResponse: okOpenResponse, inspectFacts, bindDevice, @@ -194,7 +203,7 @@ test('attaches the initial INTERACTIVE snapshot by delegating to the existing sn }, sessionName: 'default', logPath: '/tmp/daemon.log', - sessionStore: {}, + sessionStore, inspectFacts, bindDevice, }); @@ -224,9 +233,9 @@ test('a snapshot-capture failure never masks the successful open', async () => { const result = await composeOpenWithInitialSnapshot({ req: baseRequest({ flags: { foreground: true } }), - sessionName: 'default', + ref, logPath: '/tmp/daemon.log', - sessionStore: {} as never, + sessionStore, openResponse: { ok: true, data: { session: 'default', warnings: ['pre-existing warning'] } }, inspectFacts, bindDevice, @@ -266,9 +275,9 @@ test('a THROWN snapshot-capture failure never masks the successful open either', const result = await composeOpenWithInitialSnapshot({ req: baseRequest({ flags: { foreground: true } }), - sessionName: 'default', + ref, logPath: '/tmp/daemon.log', - sessionStore: {} as never, + sessionStore, openResponse: { ok: true, data: { session: 'default' } }, inspectFacts, bindDevice, diff --git a/src/daemon/session-lifecycle/internal/session-open-execution.ts b/src/daemon/session-lifecycle/internal/session-open-execution.ts index 49d2e89d5b..980f3a54ca 100644 --- a/src/daemon/session-lifecycle/internal/session-open-execution.ts +++ b/src/daemon/session-lifecycle/internal/session-open-execution.ts @@ -43,7 +43,7 @@ import { buildForeignWorkspaceSessionConflict, } from '../../session-recovery-hints.ts'; import { describeOpenWaitForRefusal } from '../../open-device-contention-wait.ts'; -import { isImplicitSessionScopeConflict } from '../../session-routing.ts'; +import { isImplicitSessionScopeConflict, resolvePublicSessionName } from '../../session-routing.ts'; import { applicationLifecycleExecutionFromRequest } from '../../application-lifecycle-execution.ts'; import { abandonDeviceClaim, @@ -65,6 +65,10 @@ import { requireAllocatorHeldDeviceClaim } from '../../device/device-claim-alloc import { deviceClaimRuleForOwner } from '../../device/device-claim-rule.ts'; import { errorResponse, type DaemonFailureResponse } from '@agent-device/kernel/contracts'; +export type SessionOpenResult = + | Readonly<{ type: 'opened'; response: DaemonResponse; ref: SessionRef }> + | Readonly<{ type: 'response'; response: DaemonResponse }>; + type OpenTiming = { totalDurationMs?: number; relaunchCloseDurationMs?: number; @@ -185,7 +189,7 @@ export async function completeOpenCommand(params: { /** The stale claim this open released before taking the device, when there was one. */ tookOverDeviceClaim?: TakenOverDeviceClaim; selection?: DeviceSelectionResult; -}): Promise { +}): Promise { const { req, sessionName, @@ -222,7 +226,7 @@ export async function completeOpenCommand(params: { appName, existingRef, }); - if (provisionalSession.type === 'response') return provisionalSession.response; + if (provisionalSession.type === 'response') return provisionalSession; const openDispatchRef = provisionalSession.ref; requireOpenSessionAdmission(sessionStore, sessionName, openDispatchRef); const openDispatchSession = openDispatchRef @@ -257,7 +261,10 @@ export async function completeOpenCommand(params: { : undefined; if (isRequestCanceled(req.meta?.requestId)) { const canceled = createRequestCanceledError(); - return errorResponse(canceled.code, canceled.message, canceled.details); + return { + type: 'response', + response: errorResponse(canceled.code, canceled.message, canceled.details), + }; } requireOpenSessionAdmission(sessionStore, sessionName, openDispatchRef); @@ -270,20 +277,6 @@ export async function completeOpenCommand(params: { androidFreshnessBaseline: freshnessBaseline, }); } - const nextRef = publishOpenSession({ - req, - sessionStore, - sessionName, - existingRef: openDispatchRef, - device, - surface, - appBundleId: sessionAppBundleId, - appName, - deviceClaim, - }); - const nextSession = sessionStore.requireCurrent(nextRef); - if (req.runtime !== undefined) - setSessionRuntimeHintsForOpen(sessionStore, sessionName, runtimeHints); const sessionStateDir = sessionStore.ensureSessionDir(sessionName); const requestLogPath = resolveSessionRequestLogPath( sessionStateDir, @@ -297,7 +290,7 @@ export async function completeOpenCommand(params: { data: timing, }); const openResult = buildOpenResult({ - sessionName: nextSession.name, + sessionName: openDispatchSession?.name ?? resolvePublicSessionName(req), sessionStateDir, runnerLogPath: resolveSessionRunnerLogPath(sessionStateDir), requestLogPath, @@ -317,6 +310,20 @@ export async function completeOpenCommand(params: { if (tookOverDeviceClaim) { appendResponseWarning(openResult, deviceClaimTakeoverWarning(tookOverDeviceClaim)); } + const nextRef = publishOpenSession({ + req, + sessionStore, + sessionName, + existingRef: openDispatchRef, + device, + surface, + appBundleId: sessionAppBundleId, + appName, + deviceClaim, + }); + const nextSession = sessionStore.requireCurrent(nextRef); + if (req.runtime !== undefined) + setSessionRuntimeHintsForOpen(sessionStore, sessionName, runtimeHints); applyOrdinaryScriptRecordingOpenOutcome({ session: nextSession, existingSession: existingRef ? nextSession : undefined, @@ -330,7 +337,7 @@ export async function completeOpenCommand(params: { runtime: req.runtime !== undefined ? runtimeHints : undefined, result: openResult, }); - return { ok: true, data: openResult }; + return { type: 'opened', ref: nextRef, response: { ok: true, data: openResult } }; } async function prepareOpenDispatchSession(params: { @@ -445,7 +452,7 @@ export async function openNewSessionWithDeviceClaim(params: { clearRuntimeHints?: RuntimeHintClearOperation; reconcileOrphanedDeviceClaim: DeviceClaimReconciler; selection?: DeviceSelectionResult; -}): Promise { +}): Promise { const { req, sessionName, @@ -463,7 +470,7 @@ export async function openNewSessionWithDeviceClaim(params: { } = params; requireOpenSessionAdmission(sessionStore, sessionName, undefined); const conflict = findNewSessionDeviceConflict({ req, device, sessionStore }); - if (conflict) return conflict; + if (conflict) return { type: 'response', response: conflict }; const ownerClaim = await acquireDeviceClaimForOwner({ req, @@ -474,8 +481,11 @@ export async function openNewSessionWithDeviceClaim(params: { reconcileOrphanedDeviceClaim, }); if (ownerClaim.status === 'conflict') - return buildDeviceClaimConflictError(device, ownerClaim.conflict); - if (ownerClaim.status === 'refused') return ownerClaim.response; + return { + type: 'response', + response: buildDeviceClaimConflictError(device, ownerClaim.conflict), + }; + if (ownerClaim.status === 'refused') return { type: 'response', response: ownerClaim.response }; const deviceClaim = ownerClaim.status === 'acquired' ? ownerClaim.ownership : undefined; const tookOverDeviceClaim = ownerClaim.status === 'acquired' ? ownerClaim.tookOver : undefined; const effects: NewSessionOpenEffects = { mayHaveStarted: false }; @@ -500,14 +510,14 @@ export async function openNewSessionWithDeviceClaim(params: { }); if (details.type === 'response') { await rollbackClaim(); - return details.response; + return { type: 'response', response: details.response }; } // Preparation can boot the device or warm caches, but it cannot establish session ownership. // Stamping here is what covers a boot preparation caused for this very open; from // `completeOpenCommand` on, a relaunch-close or a runtime-hint write may already have touched the // app, so a failure from that point cannot prove ownership was never established. const reclaimed = await renewOpenSessionClaim(device, deviceClaim); - if (reclaimed) return reclaimed; + if (reclaimed) return { type: 'response', response: reclaimed }; effects.mayHaveStarted = true; const requestedPositionals = req.positionals ?? []; // `open ` carries both positionals; only `--foreground`, which has none, gets its @@ -537,7 +547,7 @@ export async function openNewSessionWithDeviceClaim(params: { tookOverDeviceClaim, selection, }); - if (!response.ok) await rollbackClaim(); + if (!response.response.ok) await rollbackClaim(); return response; } catch (error) { await rollbackClaim(); diff --git a/src/daemon/session-lifecycle/internal/session-open-foreground.ts b/src/daemon/session-lifecycle/internal/session-open-foreground.ts index c222ce2bed..3b3a095834 100644 --- a/src/daemon/session-lifecycle/internal/session-open-foreground.ts +++ b/src/daemon/session-lifecycle/internal/session-open-foreground.ts @@ -1,5 +1,6 @@ import { normalizeError, type NormalizedError } from '@agent-device/kernel/errors'; import { dispatchSnapshotViaRuntime } from '../../snapshot-runtime.ts'; +import type { SessionRef } from '../../session-state.ts'; import type { SessionStore } from '../../session-store.ts'; import type { DaemonRequest, DaemonResponse } from '../../daemon-request.ts'; import type { @@ -102,17 +103,18 @@ export async function resolveForegroundOpenRequest(params: { */ export async function composeOpenWithInitialSnapshot(params: { req: DaemonRequest; - sessionName: string; + ref: SessionRef; logPath: string; sessionStore: SessionStore; openResponse: DaemonResponse; inspectFacts: InspectDeviceRuntimeFacts; bindDevice: BindDeviceRuntime; }): Promise { - const { req, sessionName, logPath, sessionStore, openResponse } = params; + const { req, ref, logPath, sessionStore, openResponse } = params; if (!openResponse.ok || req.flags?.foreground !== true) return openResponse; try { + sessionStore.requireCurrent(ref); const snapshotResponse = await dispatchSnapshotViaRuntime({ req: { ...req, @@ -123,7 +125,7 @@ export async function composeOpenWithInitialSnapshot(params: { // key the snapshot runtime reads as `interactiveOnly`. flags: { ...req.flags, snapshotInteractiveOnly: true }, }, - sessionName, + sessionName: ref.address, logPath, sessionStore, inspectFacts: params.inspectFacts, diff --git a/src/daemon/session-lifecycle/internal/session-open.ts b/src/daemon/session-lifecycle/internal/session-open.ts index 7939036390..8cc765fa3c 100644 --- a/src/daemon/session-lifecycle/internal/session-open.ts +++ b/src/daemon/session-lifecycle/internal/session-open.ts @@ -40,6 +40,7 @@ import { openNewSessionWithDeviceClaim, renewOpenSessionClaim, type OpenApplicationRuntime, + type SessionOpenResult, type RuntimeHintApplyOperation, type RuntimeHintClearOperation, } from './session-open-execution.ts'; @@ -150,7 +151,7 @@ async function resolveOpenRuntimePlanAdmission(params: { } // fallow-ignore-next-line complexity -async function handleOpenCommand(params: SessionOpenCommandInput): Promise { +async function handleOpenCommand(params: SessionOpenCommandInput): Promise { const { sessionName, logPath, sessionStore } = params; const existingRef = sessionStore.lookup(sessionName); @@ -158,17 +159,21 @@ async function handleOpenCommand(params: SessionOpenCommandInput): Promise { - const openResponse = await handleOpenCommand(params); - if (!openResponse.ok || params.req.flags?.foreground !== true) return openResponse; + const result = await handleOpenCommand(params); + const openResponse = result.response; + if (result.type !== 'opened' || params.req.flags?.foreground !== true) return openResponse; return await composeOpenWithInitialSnapshot({ ...params, inspectFacts: requireRuntimeFacts(params.inspectFacts), bindDevice: requireRuntimeBinding(params.bindDevice), openResponse, + ref: result.ref, }); } From 08940db32a40605593e07fe92ee3170366280663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 10:34:15 +0200 Subject: [PATCH 38/44] chore(gates): declare the owning open lease and claim helper --- scripts/layering/session-state.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/layering/session-state.ts b/scripts/layering/session-state.ts index b13653694f..fa44cc254a 100644 --- a/scripts/layering/session-state.ts +++ b/scripts/layering/session-state.ts @@ -96,8 +96,8 @@ export const SESSION_STATE_FIELD_OWNERS: Readonlyplaceholder registry is populated and consulted only at the From f3a664c0b6b6941c8b3f51af96dff04702f71f6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 10:58:59 +0200 Subject: [PATCH 39/44] fix: preserve capture records and discard retired settle observations --- src/daemon/__tests__/generic-settle.test.ts | 101 ++++++++++++++++-- .../selector-capture-runtime.test.ts | 45 ++++++++ src/daemon/__tests__/session-snapshot.test.ts | 40 ++++++- .../snapshot-command-runtime.test.ts | 13 ++- src/daemon/generic-settle.ts | 2 + .../interaction-ios-tap-outcome.test.ts | 29 +++++ .../internal/interaction-ios-tap-outcome.ts | 14 ++- .../interaction-touch-reference-frame.ts | 4 +- src/daemon/selector-capture-runtime.ts | 5 +- src/daemon/session-snapshot.ts | 3 +- src/daemon/snapshot-command-runtime.ts | 18 ++-- 11 files changed, 244 insertions(+), 30 deletions(-) diff --git a/src/daemon/__tests__/generic-settle.test.ts b/src/daemon/__tests__/generic-settle.test.ts index 5ae2c10d04..033cca5c5a 100644 --- a/src/daemon/__tests__/generic-settle.test.ts +++ b/src/daemon/__tests__/generic-settle.test.ts @@ -13,7 +13,7 @@ import { activateCompleteRefFrame, refFrameState } from '../ref-frame.ts'; import { setSessionSnapshot } from '../session-snapshot.ts'; import type { SessionStore } from '../session-store.ts'; import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; -import type { SessionState } from '../session-state.ts'; +import type { SessionRef, SessionState } from '../session-state.ts'; import { buildSnapshotState } from '@agent-device/capture-kit/snapshot-state'; // #1638 `--settle` on the GENERIC daemon route (scroll/back): the settled diff, @@ -85,19 +85,19 @@ type SettlePayload = { const captureObservations: Array<{ postGestureStabilizationPending: boolean }> = []; async function emulateCaptureSnapshotForSession( - session: SessionState, + ref: SessionRef, flags: CommandFlags | undefined, sessionStore: SessionStore, options: { interactiveOnly: boolean }, ) { captureObservations.push({ - postGestureStabilizationPending: session.postGestureStabilization !== undefined, + postGestureStabilizationPending: + sessionStore.requireCurrent(ref).postGestureStabilization !== undefined, }); const effectiveFlags = { ...(flags ?? {}), snapshotInteractiveOnly: options.interactiveOnly }; const snapshotData = (await mockDispatch('snapshot')) as Parameters[0]; const snapshot = buildSnapshotState(snapshotData ?? {}, effectiveFlags); - setSessionSnapshot(session, snapshot); - sessionStore.set(session.name, session); + setSessionSnapshot(sessionStore.requireCurrent(ref), snapshot); return snapshot; } @@ -210,8 +210,7 @@ beforeEach(() => { mockCaptureSnapshotForSession.mockImplementation( (...args: Parameters) => { const [ref, flags, sessionStore, _contextFromFlags, options] = args; - const session = sessionStore.requireCurrent(ref); - return emulateCaptureSnapshotForSession(session, flags, sessionStore, options); + return emulateCaptureSnapshotForSession(ref, flags, sessionStore, options); }, ); }); @@ -620,3 +619,91 @@ test('an orphaned --settle-quiet is rejected before the command dispatches', asy expect(response.error?.code).toBe('INVALID_ARGS'); expect(response.error?.message).toContain('--settle-quiet'); }); + +test('scroll stays successful when its lifetime retires during optional settle observation', async () => { + const store = makeSessionStore(); + const name = 'generic-settle-retired'; + const session = seedSession(name, store); + const ref = store.lookup(name)!; + mockCommandDispatch([AFTER_NODES]); + mockCaptureSnapshotForSession.mockImplementationOnce( + async (boundRef, flags, sessionStore, _context, options) => { + const observed = await emulateCaptureSnapshotForSession( + boundRef, + flags, + sessionStore, + options, + ); + queueMicrotask(() => { + store.retire(ref); + store.publish(name, makeIosSession(name)); + }); + return observed; + }, + ); + const response = await dispatchGeneric({ + sessionName: name, + sessionStore: store, + session, + command: 'scroll', + positionals: ['down'], + flags: { ...SETTLE_FLAGS }, + }); + expect(expectOkData(response).settle).toBeUndefined(); + expect(store.get(name)?.snapshot).toBeUndefined(); + expect(store.get(name)?.actions).toEqual([]); +}); + +for (const change of ['rebuild', 'retire'] as const) { + test(`held generic settle capture respects its lifetime after ${change}`, async () => { + const store = makeSessionStore(); + const name = `generic-held-settle-${change}`; + const session = seedSession(name, store); + const ref = store.lookup(name)!; + let entered!: () => void; + let release!: () => void; + const reached = new Promise((resolve) => { + entered = resolve; + }); + const held = new Promise((resolve) => { + release = resolve; + }); + mockDispatch.mockImplementation(async (command) => { + if (command !== 'snapshot') return {}; + entered(); + await held; + return snapshotPayload({ nodes: AFTER_NODES }); + }); + const pending = dispatchGeneric({ + sessionName: name, + sessionStore: store, + session, + command: 'scroll', + positionals: ['down'], + flags: { ...SETTLE_FLAGS }, + }); + try { + await reached; + const trace = { outPath: 'latest-trace', startedAt: 1 }; + const successor = makeIosSession(name); + if (change === 'rebuild') store.update(ref, { trace }); + else { + store.retire(ref); + store.publish(name, successor); + } + release(); + const data = expectOkData(await pending); + if (change === 'rebuild') { + expect(store.requireCurrent(ref).trace).toBe(trace); + expect(store.requireCurrent(ref).snapshot?.nodes[1]?.label).toBe('Load more'); + } else { + expect(data.settle).toBeUndefined(); + expect(store.get(name)).toBe(successor); + expect(successor.snapshot).toBeUndefined(); + } + } finally { + release(); + await pending; + } + }); +} diff --git a/src/daemon/__tests__/selector-capture-runtime.test.ts b/src/daemon/__tests__/selector-capture-runtime.test.ts index d90131a822..9230151055 100644 --- a/src/daemon/__tests__/selector-capture-runtime.test.ts +++ b/src/daemon/__tests__/selector-capture-runtime.test.ts @@ -377,3 +377,48 @@ test('a held selector capture updates the matching rebuilt record without restor await running.catch(() => {}); } }); + +test('selector capture and sparse recovery both use current same-lifetime app metadata', async () => { + const { runtime, sessionName, sessionStore } = makeCaptureRuntime('selector-current-metadata'); + const ref = sessionStore.lookup(sessionName)!; + sessionStore.update(ref, { appBundleId: 'before-first-capture' }); + boundCapture + .mockImplementationOnce(async () => { + sessionStore.update(ref, { appBundleId: 'before-recovery-capture' }); + return { + backend: 'xctest', + producer: 'apple-runner', + nodes: [{ index: 0, type: 'Application' }], + }; + }) + .mockResolvedValueOnce({ + backend: 'xctest', + producer: 'apple-runner', + nodes: [{ index: 0, type: 'Button', label: 'Recovered' }], + }); + const result = await runtime.capture({ + flags: { snapshotInteractiveOnly: true }, + recovery: { legacyIosSparse: { query: 'Search', shouldScope: false } }, + }); + expect(result.snapshot.nodes[0]?.label).toBe('Recovered'); + expect(boundCapture.mock.calls.map(([input]) => input.options?.appBundleId)).toEqual([ + 'before-first-capture', + 'before-recovery-capture', + ]); + expect(sessionStore.requireCurrent(ref).appBundleId).toBe('before-recovery-capture'); +}); + +test('a retired selector runtime refuses even a reusable cached capture without touching its successor', async () => { + const { runtime, sessionName, sessionStore } = makeCaptureRuntime('selector-retired-cache'); + const ref = sessionStore.lookup(sessionName)!; + await runtime.capture({ flags: {} }); + sessionStore.retire(ref); + const successor = sessionStore.publish(sessionName, ref.session); + await expect(runtime.capture({ flags: {} })).rejects.toThrow( + expect.objectContaining({ + details: expect.objectContaining({ reason: 'session_lifetime_ended' }), + }), + ); + expect(boundCapture).toHaveBeenCalledOnce(); + expect(sessionStore.requireCurrent(successor)).toBe(ref.session); +}); diff --git a/src/daemon/__tests__/session-snapshot.test.ts b/src/daemon/__tests__/session-snapshot.test.ts index c7b6fa2458..41c9ee2002 100644 --- a/src/daemon/__tests__/session-snapshot.test.ts +++ b/src/daemon/__tests__/session-snapshot.test.ts @@ -1,8 +1,11 @@ import { expect, test } from 'vitest'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import type { SettleObservation } from '@agent-device/contracts/interaction'; import type { SnapshotState } from '@agent-device/kernel/snapshot'; import type { SessionState } from '../session-state.ts'; import { markSessionPartialRefsIssued, + issueSettleRefs, resolveRefStalenessWarning, setSessionSnapshot, setCommandSnapshot, @@ -179,12 +182,14 @@ test('a ref pinned before a diff keeps resolving: the diff advances the counter, // `diff` replaces the stored tree, so lineage advances the counter — but it passes // `issuesRefsToClient: false`, so it never reactivates the frame. const afterDiff: SessionState = { ...session }; + const diffSnapshot = makeSnapshot(); setCommandSnapshot(afterDiff, { - snapshot: afterDiff.snapshot!, + snapshot: diffSnapshot, scopeSource: undefined, keptCurrentSnapshot: false, previousGeneration: session.snapshotGeneration, }); + expect(afterDiff.snapshot).toBe(diffSnapshot); expect(afterDiff.snapshotGeneration).not.toBe(session.snapshotGeneration); expect(refFrameEpoch(afterDiff)).toBe(issuedAt); @@ -220,3 +225,36 @@ test('keeping the current snapshot leaves the counter alone', () => { }); expect(session.snapshotGeneration).toBe(before); }); + +for (const retire of [false, true]) { + test(`settle ref issuance checks lifetime before publishing (${retire ? 'retired' : 'live'})`, () => { + const store = makeSessionStore(); + const session = makeSession(); + setSessionSnapshot(session, makeSnapshot()); + const ref = store.publish('cwd:settle:default', session); + const priorFrame = refFrame(session); + if (retire) { + store.retire(ref); + store.publish(ref.address, session); + } + const observation: SettleObservation = { + settled: true, + waitedMs: 1, + captures: 2, + quietMs: 1, + timeoutMs: 20, + diff: { + summary: { additions: 1, removals: 0, unchanged: 0 }, + lines: [{ kind: 'added', text: 'new button', ref: 'e1' }], + }, + }; + const generation = issueSettleRefs(ref, store, observation); + if (retire) { + expect(generation).toBeUndefined(); + expect(refFrame(session)).toBe(priorFrame); + } else { + expect(generation).toBe(session.snapshotGeneration); + expect(refFrameScope(session)).toEqual(new Set(['e1'])); + } + }); +} diff --git a/src/daemon/__tests__/snapshot-command-runtime.test.ts b/src/daemon/__tests__/snapshot-command-runtime.test.ts index 2a17a1d6ea..80002fcb8d 100644 --- a/src/daemon/__tests__/snapshot-command-runtime.test.ts +++ b/src/daemon/__tests__/snapshot-command-runtime.test.ts @@ -86,7 +86,7 @@ for (const command of ['snapshot', 'diff snapshot'] as const) { }); } -for (const change of ['rebuild', 'replace'] as const) { +for (const change of ['unchanged', 'rebuild', 'replace'] as const) { test(`snapshot completion respects a scoped lifetime after ${change}`, async () => { const sessionStore = makeSessionStore(); const address = 'cwd:snapshot-completion:default'; @@ -117,13 +117,18 @@ for (const change of ['rebuild', 'replace'] as const) { ); try { await entered.promise; - if (change === 'rebuild') { - const trace = { outPath: 'intervening-trace', startedAt: 1 }; - sessionStore.update(ref, { trace }); + if (change !== 'replace') { + const trace = + change === 'rebuild' ? { outPath: 'intervening-trace', startedAt: 1 } : ref.session.trace; + if (change === 'rebuild') sessionStore.update(ref, { trace }); + const priorRecord = sessionStore.requireCurrent(ref); release.resolve(); expect(await result).toMatchObject({ response: { ok: true } }); const current = sessionStore.requireCurrent(ref); expect(current.trace).toBe(trace); + expect(current).not.toBe(priorRecord); + expect(priorRecord.snapshot).toBeUndefined(); + expect(ref.session.snapshot).toBeUndefined(); expect(current.snapshot?.nodes[0]?.label).toBe('Captured'); } else { sessionStore.retire(ref); diff --git a/src/daemon/generic-settle.ts b/src/daemon/generic-settle.ts index 70e1ead5d6..9bd5d09a63 100644 --- a/src/daemon/generic-settle.ts +++ b/src/daemon/generic-settle.ts @@ -110,6 +110,8 @@ async function observeSettled( session: context.sessionName, requestId: context.req.meta?.requestId, }); + if (context.sessionRef && !context.sessionStore.resolveCurrent(context.sessionRef)) + return undefined; const refsGeneration = issueSettleRefs(context.sessionRef, context.sessionStore, observation); return refsGeneration === undefined ? observation : { ...observation, refsGeneration }; } 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 d0163f192d..30ed2bf4b5 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 @@ -745,3 +745,32 @@ test('corroborated runtime taps retain target evidence through save and replay', expect(pressCount).toBe(2); expect(snapshotCount).toBeGreaterThanOrEqual(4); }); + +test('a tap corroborates against its admitted snapshot after a same-lifetime record rebuild', async () => { + const store = makeSessionStore(); + const name = 'ios-admitted-tap-baseline'; + const ref = store.publish( + name, + makeIosSession(name, { + appBundleId: 'com.example.app', + snapshot: snapshot(imageViewerNodes), + }), + ); + store.update(ref, { snapshot: snapshot(profileNodes) }); + legacyDispatchCapture.mockImplementation(async (_device, command) => { + if (command === 'press') throw new AppError('XCTEST_RECORDED_FAILURE', 'tap failed'); + if (command === 'snapshot') return snapshotPayload(imageViewerNodes); + return {}; + }); + const response = await handleInteractionCommands({ + req: { token: 'test', session: name, command: 'click', positionals: ['104', '222'], flags: {} }, + sessionName: name, + sessionRef: ref, + sessionStore: store, + contextFromFlags, + ...getRuntimeBindings(), + }); + expect(response?.ok).toBe(true); + if (!response?.ok) throw new Error('landed tap should be corroborated'); + expect(response.data?.warning).toMatch(/same-scope post-action accessibility capture changed/); +}); diff --git a/src/daemon/interaction/internal/interaction-ios-tap-outcome.ts b/src/daemon/interaction/internal/interaction-ios-tap-outcome.ts index 977114c8d6..5fd0566d49 100644 --- a/src/daemon/interaction/internal/interaction-ios-tap-outcome.ts +++ b/src/daemon/interaction/internal/interaction-ios-tap-outcome.ts @@ -11,7 +11,7 @@ import { getRequestSignal } from '@agent-device/host-kit/request'; import { isLocalIosRunnerSession } from '../../direct-ios-selector.ts'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import type { SessionStore } from '../../session-store.ts'; -import type { SessionRef } from '../../session-state.ts'; +import type { SessionRef, SessionState } from '../../session-state.ts'; import type { BoundContextFromFlags, CaptureSnapshotForSession } from './types.ts'; const XCTEST_RECORDED_FAILURE = 'XCTEST_RECORDED_FAILURE'; @@ -54,8 +54,9 @@ export type IosTapCorroboration = { export async function corroborateIosTapFailure( params: IosTapCorroborationParams, ): Promise { - if (!canCorroborateIosTapFailure(params)) return undefined; - const baseline = readCorroborationBaseline(params.ref.session.snapshot); + const session = params.sessionStore.resolveCurrent(params.ref); + if (!session || !canCorroborateIosTapFailure(params, session)) return undefined; + const baseline = readCorroborationBaseline(session.snapshot); if (!baseline) return undefined; const after = await captureCorroborationSnapshot( @@ -69,11 +70,14 @@ export async function corroborateIosTapFailure( return compareCorroborationEvidence(baseline.snapshot, after, params.command); } -function canCorroborateIosTapFailure(params: IosTapCorroborationParams): boolean { +function canCorroborateIosTapFailure( + params: IosTapCorroborationParams, + session: SessionState, +): boolean { return ( isTapCommand(params.command) && asAppError(params.error).code === XCTEST_RECORDED_FAILURE && - isLocalIosRunnerSession(params.ref.session, { skipPendingPostGestureStabilization: false }) + isLocalIosRunnerSession(session, { skipPendingPostGestureStabilization: false }) ); } diff --git a/src/daemon/interaction/internal/interaction-touch-reference-frame.ts b/src/daemon/interaction/internal/interaction-touch-reference-frame.ts index ecf4a54b22..4edc7a8c38 100644 --- a/src/daemon/interaction/internal/interaction-touch-reference-frame.ts +++ b/src/daemon/interaction/internal/interaction-touch-reference-frame.ts @@ -72,7 +72,9 @@ export async function resolveDirectTouchReferenceFrameSafely(params: { level: 'warn', phase: 'touch_reference_frame_resolve_failed', data: { - platform: params.ref.session.device.platform, + platform: + params.sessionStore.resolveCurrent(params.ref)?.device.platform ?? + params.ref.session.device.platform, error: error instanceof Error ? error.message : String(error), }, }); diff --git a/src/daemon/selector-capture-runtime.ts b/src/daemon/selector-capture-runtime.ts index 2cfd92c54d..508ffca20b 100644 --- a/src/daemon/selector-capture-runtime.ts +++ b/src/daemon/selector-capture-runtime.ts @@ -197,6 +197,7 @@ async function runCapture( snapshotScope: string | undefined, interactiveOnly = request.flags?.snapshotInteractiveOnly, ): Promise { + const session = params.ref ? params.sessionStore.requireCurrent(params.ref) : undefined; const flags = { ...request.flags, snapshotInteractiveOnly: interactiveOnly, @@ -204,7 +205,7 @@ async function runCapture( const boundCapture = params.capture; const capture = await captureSnapshot({ device: params.device, - session: params.session, + session, flags, outPath: request.outPath ?? params.req.flags?.out, logPath: params.logPath ?? '', @@ -217,7 +218,7 @@ async function runCapture( flags, logPath: params.logPath ?? '', meta: params.req.meta, - session: params.session, + session, snapshotScope, includeRects: request.includeRects, // The POLL's remaining budget, not the request's. A binding's signal is fixed at diff --git a/src/daemon/session-snapshot.ts b/src/daemon/session-snapshot.ts index cfb26a848f..96efd49f5b 100644 --- a/src/daemon/session-snapshot.ts +++ b/src/daemon/session-snapshot.ts @@ -130,7 +130,8 @@ export function issueSettleRefs( settle: SettleObservation | undefined, ): number | undefined { if (!ref || !settle?.diff) return undefined; - const session = sessionStore.requireCurrent(ref); + const session = sessionStore.resolveCurrent(ref); + if (!session) return undefined; markSessionPartialRefsIssued(session, collectSettleIssuedRefBodies(settle)); return session.snapshotGeneration; } diff --git a/src/daemon/snapshot-command-runtime.ts b/src/daemon/snapshot-command-runtime.ts index 9cc847c758..6ec78b4bfb 100644 --- a/src/daemon/snapshot-command-runtime.ts +++ b/src/daemon/snapshot-command-runtime.ts @@ -146,15 +146,15 @@ function createSnapshotRuntime(params: { isRefScopedSnapshot(req), ); const snapshot = keepCurrentSnapshot ? current.snapshot : snapshotRecord.snapshot; - const nextSession: SessionState = - current ?? - createSnapshotSession({ - sessionName, - sessionScope: resolveSessionScope(req), - device, - snapshot, - appBundleId: record.appBundleId, - }); + const nextSession: SessionState = ref + ? sessionStore.update(ref, {}) + : createSnapshotSession({ + sessionName, + sessionScope: resolveSessionScope(req), + device, + snapshot, + appBundleId: record.appBundleId, + }); nextSession.appName = record.appName ?? current?.appName; setCommandSnapshot(nextSession, { snapshot, From 9403b974fc52f3b0814f55127762015b15f22ec1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 11:10:36 +0200 Subject: [PATCH 40/44] fix: omit retired settle refs after the touch frame probe --- .../interaction-touch-response.test.ts | 82 +++++++++++++++++++ .../internal/interaction-touch-response.ts | 7 +- 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-response.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-response.test.ts index a471a83106..c72da0e3eb 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-response.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-response.test.ts @@ -4,6 +4,7 @@ import { makeSessionStore } from '../../../../__tests__/test-utils/store-factory import { handleInteractionCommands } from '../../index.ts'; import { buildInteractionResponseData, + buildTargetedTouchResponsePayloads, transformTouchResponseData, } from '../interaction-touch-response.ts'; import type { PressCommandResult } from '@agent-device/contracts/interaction'; @@ -100,6 +101,87 @@ function selectorResult(readiness?: { polls: number; waitedMs: number }): PressC const WAITED_SELECTOR_RESULT = selectorResult({ polls: 3, waitedMs: 400 }); +test.each([false, true])( + 'point-touch publication after a held frame probe, retired=%s', + async (retired) => { + const sessionStore = makeSessionStore(); + const address = 'cwd:held-touch-response:default'; + const session = makeSession('default'); + session.snapshot = { + nodes: attachRefs([{ index: 0, type: 'Button', label: 'Continue' }]), + createdAt: Date.now(), + backend: 'xctest', + }; + session.snapshotGeneration = 3; + installTestScreenRecording(session); + const ref = sessionStore.publish(address, session); + let startProbe!: () => void; + let releaseProbe!: () => void; + const probing = new Promise((resolve) => { + startProbe = resolve; + }); + const released = new Promise((resolve) => { + releaseProbe = resolve; + }); + const result: PressCommandResult = { + kind: 'point', + point: { x: 10, y: 20 }, + settle: { + settled: true, + waitedMs: 25, + captures: 2, + quietMs: 25, + timeoutMs: 2000, + diff: { + summary: { additions: 1, removals: 0, unchanged: 0 }, + lines: [{ kind: 'added', text: 'Continue', ref: 'e1' }], + }, + }, + }; + const running = buildTargetedTouchResponsePayloads({ + params: { + req: { + token: 't', + command: 'press', + positionals: ['10', '20'], + session: 'default', + flags: {}, + }, + sessionName: address, + sessionRef: ref, + sessionStore, + contextFromFlags, + captureSnapshotForSession: async () => { + startProbe(); + await released; + return sessionStore.requireCurrent(ref).snapshot!; + }, + }, + session, + result, + staleRefsWarning: undefined, + extra: {}, + }); + await probing; + let successor; + if (retired) { + sessionStore.retire(ref); + successor = sessionStore.publish(address, makeSession('default')); + } + releaseProbe(); + const payloads = await running; + expect(payloads.responseData.x).toBe(10); + if (retired) { + expect(payloads.responseData.settle).toBeUndefined(); + expect(payloads.result.settle).toBeUndefined(); + expect(sessionStore.lookup(address)).toEqual(successor); + expect(successor?.session.snapshot).toBeUndefined(); + } else { + expect(payloads.responseData.settle).toMatchObject({ refsGeneration: 3, settled: true }); + } + }, +); + test('the response builder reports the resolved wait and keeps the prior warning', () => { const { responseData, result } = buildInteractionResponseData({ source: { kind: 'runtime', result: WAITED_SELECTOR_RESULT }, diff --git a/src/daemon/interaction/internal/interaction-touch-response.ts b/src/daemon/interaction/internal/interaction-touch-response.ts index e7235e216e..c13bce6f62 100644 --- a/src/daemon/interaction/internal/interaction-touch-response.ts +++ b/src/daemon/interaction/internal/interaction-touch-response.ts @@ -309,15 +309,18 @@ export async function buildTargetedTouchResponsePayloads(params: { handlerParams.sessionStore.requireCurrent(handlerParams.sessionRef!).snapshot?.nodes ?? [], ); + const currentResult = handlerParams.sessionStore.resolveCurrent(handlerParams.sessionRef!) + ? result + : { ...result, settle: undefined }; return buildInteractionResponseData({ - source: { kind: 'runtime', result, publicData }, + source: { kind: 'runtime', result: currentResult, publicData }, referenceFrame, extra, staleRefsWarning: params.staleRefsWarning, settleRefsGeneration: issueSettleRefs( handlerParams.sessionRef, handlerParams.sessionStore, - result.settle, + currentResult.settle, ), }); } From 03e783a8680b2cd64d9f30fcee119349ff6d6b3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 11:51:04 +0200 Subject: [PATCH 41/44] refactor: bind action recording and metadata patches to session lifetimes --- src/daemon/__tests__/generic-settle.test.ts | 47 ++++--- .../session-replay-repair-acceptance.test.ts | 2 +- .../session-replay-repair-empty-tail.test.ts | 8 +- .../session-replay-repair-loop.test.ts | 9 +- ...ion-replay-repair-record-exclusion.test.ts | 15 ++- ...on-replay-repair-transaction-force.test.ts | 5 +- .../session-replay-repair-transaction.test.ts | 2 +- .../session-replay-repair.fixtures.ts | 3 +- .../request-save-script-transports.test.ts | 2 +- .../__tests__/selector-recording.test.ts | 39 +++--- src/daemon/__tests__/selector-runtime.test.ts | 121 +++++++++++++++++- .../__tests__/session-store-lifetime.test.ts | 49 +++++++ src/daemon/__tests__/session-store.test.ts | 64 ++++----- src/daemon/__tests__/wait-runtime.test.ts | 49 +++++++ .../__tests__/session-reinstall.test.ts | 70 ++++++++++ .../session-selector-dispatch.test.ts | 86 +++++++++++++ src/daemon/handlers/react-native.ts | 6 +- src/daemon/handlers/record-runtime.ts | 4 +- src/daemon/handlers/session-app-deployment.ts | 33 +++-- .../handlers/session-app-source-deployment.ts | 7 +- src/daemon/handlers/session-clipboard.ts | 9 +- .../handlers/session-selector-dispatch.ts | 62 ++++----- src/daemon/handlers/snapshot-alert.ts | 10 +- src/daemon/handlers/snapshot-settings.ts | 19 ++- src/daemon/handlers/snapshot.ts | 16 ++- src/daemon/handlers/trace-runtime.ts | 20 +-- .../__tests__/interaction-common.test.ts | 4 +- src/daemon/interaction/internal/find.ts | 31 +++-- .../internal/interaction-gesture.ts | 2 +- .../internal/interaction-runtime.ts | 9 +- .../internal/interaction-touch-direct-ios.ts | 8 +- .../internal/interaction-touch-runtime.ts | 4 +- .../interaction/internal/interaction.ts | 2 +- src/daemon/request-generic-dispatch.ts | 34 ++--- src/daemon/request-router.ts | 7 +- src/daemon/selector-recording.ts | 9 +- src/daemon/selector-runtime.ts | 6 +- src/daemon/session-action-recorder.ts | 12 +- .../session-open-execution-runtime.test.ts | 6 +- .../internal/session-close-script.ts | 13 +- .../internal/session-open-execution.ts | 2 +- .../internal/session-perf-runtime.ts | 9 +- src/daemon/session-store.ts | 25 +--- src/daemon/snapshot-command-runtime.ts | 9 +- src/daemon/snapshot-session.ts | 8 +- src/daemon/wait-runtime.ts | 2 +- 46 files changed, 675 insertions(+), 284 deletions(-) diff --git a/src/daemon/__tests__/generic-settle.test.ts b/src/daemon/__tests__/generic-settle.test.ts index 033cca5c5a..afddd3d5f5 100644 --- a/src/daemon/__tests__/generic-settle.test.ts +++ b/src/daemon/__tests__/generic-settle.test.ts @@ -187,7 +187,7 @@ async function dispatchGeneric(params: { }; return await dispatchGenericCommand({ req, - session: params.session, + ref: params.sessionStore.lookup(params.sessionName)!, sessionName: params.sessionName, logPath: '', sessionStore: params.sessionStore, @@ -549,32 +549,31 @@ test('scroll without --settle takes no observation captures and issues no refs', expect(refFrameState(sessionStore.get(sessionName) as SessionState)).toBe('expired'); }); -test('a settle observation that cannot build a runtime degrades instead of failing the action', async () => { +test('an ended generic lifetime is refused before dispatch or settle construction', async () => { const sessionStore = makeSessionStore(); const sessionName = 'generic-settle-evicted'; - // The session the router handed us is no longer in the store — evicted - // between dispatch and observation. Building the settle runtime throws - // SESSION_NOT_FOUND, and the observation is best-effort: the scroll already - // happened, so the response keeps its result and simply carries no settle. - const session = makeIosSession(sessionName); - setSessionSnapshot( - session, - buildSnapshotState({ nodes: BEFORE_NODES, backend: 'xctest', producer: 'apple-runner' }, {}), - ); - activateCompleteRefFrame(session); + seedSession(sessionName, sessionStore); + const ref = sessionStore.lookup(sessionName)!; + sessionStore.retire(ref); mockCommandDispatch([AFTER_NODES]); - - const response = await dispatchGeneric({ - sessionName, - sessionStore, - session, - command: 'scroll', - positionals: ['down'], - flags: { ...SETTLE_FLAGS }, - }); - - const data = expectOkData(response); - expect(data.settle).toBeUndefined(); + await expect( + dispatchGenericCommand({ + req: { + token: 't', + session: sessionName, + command: 'scroll', + positionals: ['down'], + flags: SETTLE_FLAGS, + }, + ref, + sessionName, + sessionStore, + logPath: '', + contextFromFlags, + executePlatformCommand: platformExecution, + }), + ).rejects.toMatchObject({ details: { reason: 'session_lifetime_ended' } }); + expect(mockDispatch).not.toHaveBeenCalled(); expect(captureObservations).toEqual([]); }); 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 1f9e7d44f2..617aabcd7e 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 @@ -114,7 +114,7 @@ test('a healed script survives repair + fresh-session replay: self-contained ope expect(session.actions.map((a) => a.command)).toEqual(['open']); // --- Agent presses the blessed @ref (record-and-heal): recorded live. --- - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'press', positionals: ['@e7'], flags: {}, 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 04dd93bed5..f6e7b0883d 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 @@ -153,7 +153,7 @@ test('a record-and-heal divergence on the LAST step resumes with an empty tail a expect(session.actions.map((a) => a.command)).toEqual(['open']); // --- Agent performs the corrective press (blessed @ref), recorded live. --- - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'press', positionals: ['@e6'], flags: {}, @@ -280,7 +280,7 @@ test('a manual divergence (unannotated action-failure) on the LAST step resumes // --- Agent performs the step's intent as a recorded action (blessed // @ref), recorded live. --- - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'press', positionals: ['@e6'], flags: {}, @@ -408,7 +408,7 @@ test('a caution (identity-mismatch) divergence on the LAST step resumes with an // --- Agent presses the actual (renamed) control via a blessed @ref, // recorded live — the record-and-heal-shaped repair for path (a) from // #1262 ("selector binds the wrong node on the right screen"). --- - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'press', positionals: ['@e6'], flags: {}, @@ -659,7 +659,7 @@ test('a stale --plan-digest on an empty-tail resume is rejected WITHOUT consumin expect(divergence.resume.from).toBe(3); const session = sessionStore.get(sessionName)!; - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'press', positionals: ['@e6'], flags: {}, diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair-loop.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair-loop.test.ts index d5870193a2..2a00c0e8ac 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-loop.test.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-loop.test.ts @@ -1,3 +1,4 @@ +import { storeSessionForTest } from '../../../__tests__/test-utils/store-factory.ts'; import { isSessionRecording } from '../../session-script-publication-capability.ts'; /** * ADR 0012 decision 6: `replay --save-script` arming (R1), the repair-run @@ -134,7 +135,7 @@ test('R1/R2/R6: prefix steps get fresh evidence, corrective + resumed steps land expect(divergence.resume.from).toBe(3); // --- Agent performs the corrective action live (recorded). --- - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'press', positionals: ['@e9'], flags: {}, @@ -381,13 +382,13 @@ test('a --no-record state-fix action never enters session.actions', () => { // Agent fixes app state with --no-record, then performs the real corrective // action (recorded). - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'press', positionals: ['100', '200'], flags: { noRecord: true }, result: {}, }); - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'press', positionals: ['@e9'], flags: {}, @@ -578,7 +579,7 @@ test('Fix 3: a --from resume that lands on the terminal close skips it too, lett expect(divergence.resume.from).toBe(2); const session = sessionStore.get(sessionName)!; - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'press', positionals: ['@e9'], flags: {}, diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts index 1daa91397f..e998f0bcf0 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts @@ -1,3 +1,4 @@ +import { storeSessionForTest } from '../../../__tests__/test-utils/store-factory.ts'; /** * #1271 stage 2 (ADR 0012 amendment): repair-segment default exclusion of * observation-only commands (`snapshot`/`get`/`is`/a read-only `find`), the @@ -193,14 +194,14 @@ test('diagnostic get/is reads mid-repair are excluded from the healed script by // --- The agent explores mid-repair: a couple of diagnostic reads to locate // the renamed control. Both are observation-only and the session is // repair-armed, so per the default exclusion neither is appended. --- - ctx.sessionStore.recordAction(session, { + ctx.sessionStore.recordAction(storeSessionForTest(ctx.sessionStore, session), { command: 'get', positionals: ['attrs', '@e5'], flags: {}, result: { selectorChain: ['id="save-v2"'] }, interactiveObservation: true, }); - ctx.sessionStore.recordAction(session, { + ctx.sessionStore.recordAction(storeSessionForTest(ctx.sessionStore, session), { command: 'is', positionals: ['visible', 'id="save-v2"'], flags: {}, @@ -212,7 +213,7 @@ test('diagnostic get/is reads mid-repair are excluded from the healed script by // --- The agent performs the corrective press (blessed @ref), recorded live — // a mutating action is never observation-only, so it is unaffected. --- - ctx.sessionStore.recordAction(session, { + ctx.sessionStore.recordAction(storeSessionForTest(ctx.sessionStore, session), { command: 'press', positionals: ['@e7'], flags: {}, @@ -295,7 +296,7 @@ test("a --record'ed diagnostic read lands in the healed script (the diverged-ste // --- The agent's correction for the diverged step IS itself a read (the // wave-3 E3 shape): `get attrs` on the renamed control, explicitly forced // into the heal with `--record` since it would otherwise be excluded. --- - ctx.sessionStore.recordAction(session, { + ctx.sessionStore.recordAction(storeSessionForTest(ctx.sessionStore, session), { command: 'get', positionals: ['attrs', '@e7'], flags: { record: true }, @@ -374,14 +375,14 @@ test('empty-segment guard: a --from resume refuses with an actionable --record h // --- The agent ONLY inspects — never performs a corrective action. Both // reads are excluded from `session.actions`, so nothing was recorded in // this repair segment. --- - ctx.sessionStore.recordAction(session, { + ctx.sessionStore.recordAction(storeSessionForTest(ctx.sessionStore, session), { command: 'get', positionals: ['attrs', '@e5'], flags: {}, result: { selectorChain: ['id="save-v2"'] }, interactiveObservation: true, }); - ctx.sessionStore.recordAction(session, { + ctx.sessionStore.recordAction(storeSessionForTest(ctx.sessionStore, session), { command: 'find', positionals: ['id', 'save-v2', 'exists'], flags: {}, @@ -418,7 +419,7 @@ test('non-repair authoring recording is unchanged: a read in a fresh `open --sav session.scriptPublication = authoringPublication('armed'); expect(session.scriptPublication.kind).not.toBe('repair'); - ctx.sessionStore.recordAction(session, { + ctx.sessionStore.recordAction(storeSessionForTest(ctx.sessionStore, session), { command: 'get', positionals: ['attrs', '@e5'], flags: {}, diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-force.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-force.test.ts index 83d6e03d60..4f2192f2ea 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-force.test.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-force.test.ts @@ -1,3 +1,4 @@ +import { storeSessionForTest } from '../../../__tests__/test-utils/store-factory.ts'; /** * ADR 0012 decision 6 repair-transaction `--force`/`--overwrite` semantics (#1258): a * `--save-script` target that already exists is refused at arm time, before any step runs, unless @@ -244,7 +245,7 @@ test('#1258 preflight honors PERSISTED force: a --from continuation without --fo // The agent's corrective press (blessed @ref), recorded live. const session = sessionStore.get(sessionName)!; - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'press', positionals: ['@e7'], flags: {}, @@ -317,7 +318,7 @@ test('#1258 preflight is per-target: a --from continuation RETARGETING to an exi expect(sessionTargetPath(session)).toBe(targetA); expect(sessionTargetForce(session)).toBe(true); // The agent's corrective press. - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'press', positionals: ['@e7'], flags: {}, 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 3136669a5f..a8841a4ffe 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 @@ -158,7 +158,7 @@ test('end-to-end repair transaction: cold divergence stays alive, corrective res // --- Agent performs the corrective press (blessed @ref), recorded live. --- const session = sessionStore.get(sessionName)!; - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'press', positionals: ['@e7'], flags: {}, diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair.fixtures.ts b/src/daemon/__tests__/replay-repair/session-replay-repair.fixtures.ts index bfdc799b69..ab83d1ddfc 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair.fixtures.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair.fixtures.ts @@ -1,3 +1,4 @@ +import { storeSessionForTest } from '../../../__tests__/test-utils/store-factory.ts'; import { isSessionRecording } from '../../session-script-publication-capability.ts'; /** * Shared fixtures for the ADR 0012 decision 6 repair-loop tests. The mock @@ -59,7 +60,7 @@ export function makeRecordingReplayInvoke(config: RecordingReplayInvokeConfig): } const session = resolveInvokeSession(config, req); const evidence = isSessionRecording(session) ? config.evidence?.(req) : undefined; - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: req.command, positionals: req.positionals ?? [], flags: req.flags ?? {}, diff --git a/src/daemon/__tests__/request-save-script-transports.test.ts b/src/daemon/__tests__/request-save-script-transports.test.ts index 6516da75b3..51f3f78847 100644 --- a/src/daemon/__tests__/request-save-script-transports.test.ts +++ b/src/daemon/__tests__/request-save-script-transports.test.ts @@ -289,7 +289,7 @@ test('an owner-armed session still records its target and publishes its script', // What `open`/`close --save-script` do once past the seam: arm the session, // then publish at teardown. Unchanged by the ingress rejection. - sessionStore.recordAction(session, { + sessionStore.recordAction(storeSessionForTest(sessionStore, session), { command: 'open', positionals: ['Example'], flags: { saveScript: target }, diff --git a/src/daemon/__tests__/selector-recording.test.ts b/src/daemon/__tests__/selector-recording.test.ts index ea319a2ff7..9cc5a4225a 100644 --- a/src/daemon/__tests__/selector-recording.test.ts +++ b/src/daemon/__tests__/selector-recording.test.ts @@ -48,10 +48,10 @@ test('a repair-armed session excludes get/is/find by default but keeps recording }); store.set('default', session); - recordIfSession(store, 'default', req('get'), {}); - recordIfSession(store, 'default', req('is'), {}); - recordIfSession(store, 'default', req('find'), {}); - recordIfSession(store, 'default', req('wait'), {}); + recordIfSession(store, store.lookup('default'), req('get'), {}); + recordIfSession(store, store.lookup('default'), req('is'), {}); + recordIfSession(store, store.lookup('default'), req('find'), {}); + recordIfSession(store, store.lookup('default'), req('wait'), {}); expect(store.get('default')!.actions.map((a) => a.command)).toEqual(['wait']); }); @@ -73,9 +73,9 @@ test('a repair-armed session still records get/is/find dispatched as replay plan }); store.set('default', session); - recordIfSession(store, 'default', planStepReq('get'), {}); - recordIfSession(store, 'default', planStepReq('is'), {}); - recordIfSession(store, 'default', planStepReq('find'), {}); + recordIfSession(store, store.lookup('default'), planStepReq('get'), {}); + recordIfSession(store, store.lookup('default'), planStepReq('is'), {}); + recordIfSession(store, store.lookup('default'), planStepReq('find'), {}); expect(store.get('default')!.actions.map((a) => a.command)).toEqual(['get', 'is', 'find']); }); @@ -92,9 +92,9 @@ test('--record forces get/is/find through even while repair-armed', () => { }); store.set('default', session); - recordIfSession(store, 'default', req('get', { record: true }), {}); - recordIfSession(store, 'default', req('is', { record: true }), {}); - recordIfSession(store, 'default', req('find', { record: true }), {}); + recordIfSession(store, store.lookup('default'), req('get', { record: true }), {}); + recordIfSession(store, store.lookup('default'), req('is', { record: true }), {}); + recordIfSession(store, store.lookup('default'), req('find', { record: true }), {}); expect(store.get('default')!.actions.map((a) => a.command)).toEqual(['get', 'is', 'find']); }); @@ -105,10 +105,10 @@ test('outside a repair-armed session, get/is/find/wait all record normally', () expect(session.scriptPublication).toBeUndefined(); store.set('default', session); - recordIfSession(store, 'default', req('get'), {}); - recordIfSession(store, 'default', req('is'), {}); - recordIfSession(store, 'default', req('find'), {}); - recordIfSession(store, 'default', req('wait'), {}); + recordIfSession(store, store.lookup('default'), req('get'), {}); + recordIfSession(store, store.lookup('default'), req('is'), {}); + recordIfSession(store, store.lookup('default'), req('find'), {}); + recordIfSession(store, store.lookup('default'), req('wait'), {}); expect(store.get('default')!.actions.map((a) => a.command)).toEqual([ 'get', @@ -122,7 +122,7 @@ test('wait absent records positionals without target-v1 annotation', () => { const store = makeStore(); store.set('default', makeIosSession('default')); - recordIfSession(store, 'default', waitAbsentReq(), { waitedMs: 0 }); + recordIfSession(store, store.lookup('default'), waitAbsentReq(), { waitedMs: 0 }); expect(store.get('default')!.actions[0]).toMatchObject({ command: 'wait', @@ -131,3 +131,12 @@ test('wait absent records positionals without target-v1 annotation', () => { }); expect(store.get('default')!.actions[0]?.targetEvidence).toBeUndefined(); }); + +test('sessionless recording cannot acquire a newly published address', () => { + const store = makeStore(); + const admitted = store.lookup('default'); + const session = makeIosSession('default'); + store.publish('default', session); + recordIfSession(store, admitted, req('wait'), { waitedMs: 100 }); + expect(session.actions).toEqual([]); +}); diff --git a/src/daemon/__tests__/selector-runtime.test.ts b/src/daemon/__tests__/selector-runtime.test.ts index bf16d25932..89f7ed3cb8 100644 --- a/src/daemon/__tests__/selector-runtime.test.ts +++ b/src/daemon/__tests__/selector-runtime.test.ts @@ -1,9 +1,23 @@ -import { beforeEach, test, vi } from 'vitest'; +import { beforeEach, expect, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import { AppError } from '@agent-device/kernel/errors'; import { IOS_SIMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; import { withAppleRunnerProvider } from '@agent-device/platform-apple/runner'; import type { SessionState } from '../session-state.ts'; +import { + localRuntimeOwner, + narrowDeviceBinding, + type DeviceBinding, +} from '@agent-device/contracts/platform-runtime'; +import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations'; +import { + snapshotRuntimeOperationFacts, + type SnapshotResult, +} from '@agent-device/contracts/snapshot-runtime'; +import { createUnavailableRuntimeFactsForTest } from '../../__tests__/test-utils/runtime-operation-facts.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { makeSession as makeStoredSession } from '../../__tests__/test-utils/session-factories.ts'; +import { dispatchGetViaRuntime } from '../selector-runtime.ts'; const { mockRunAppleRunnerCommand } = vi.hoisted(() => ({ mockRunAppleRunnerCommand: vi.fn(), @@ -19,6 +33,111 @@ function makeSession(): SessionState { return { name: 'default', device: IOS_SIMULATOR, createdAt: Date.now(), actions: [] }; } +test.each(['rebuild', 'retire'] as const)( + 'get text records in its captured lifetime after a held native read across %s', + async (transition) => { + const store = makeSessionStore(); + const address = 'cwd:held-get:default'; + const device = { + platform: 'web', + id: 'web', + name: 'Web', + kind: 'device', + booted: true, + } as const; + const ref = store.publish(address, makeStoredSession('default', { device })); + const owner = localRuntimeOwner('web'); + const base = createUnavailableRuntimeFactsForTest(device, owner); + const available = { available: true } as const; + const facts = { + ...base, + operations: { + ...base.operations, + ...snapshotRuntimeOperationFacts({ + capture: available, + customActions: available, + withoutActiveApp: available, + }), + readTextAtPoint: available, + }, + }; + let startRead!: () => void; + let releaseRead!: () => void; + const reading = new Promise((resolve) => { + startRead = resolve; + }); + const released = new Promise((resolve) => { + releaseRead = resolve; + }); + const capture = async (): Promise => ({ + backend: 'web', + producer: 'agent-browser', + nodes: [ + { index: 0, type: 'Window', rect: { x: 0, y: 0, width: 400, height: 800 } }, + { + index: 1, + parentIndex: 0, + type: 'TextField', + label: 'Input', + rect: { x: 20, y: 20, width: 80, height: 30 }, + hittable: true, + }, + ], + }); + const binding: DeviceBinding = { + device, + owner, + facts, + operations: { + captureSnapshot: capture, + captureSnapshotWithCustomActions: capture, + captureSnapshotWithoutActiveApp: capture, + readTextAtPoint: async () => { + startRead(); + await released; + return { status: 'read', text: 'Native value' }; + }, + }, + [Symbol.asyncDispose]: async () => {}, + }; + const running = dispatchGetViaRuntime({ + req: { + token: 't', + session: 'default', + command: 'get', + positionals: ['text', 'label="Input"'], + flags: {}, + }, + sessionName: address, + sessionStore: store, + inspectFacts: async () => facts, + bindDevice: async (_device, use) => narrowDeviceBinding(binding, use), + }); + await reading; + let current = ref.session; + if (transition === 'rebuild') + current = store.update(ref, { actions: [], appName: 'Rebuilt during read' }); + else { + store.retire(ref); + current = store.publish(address, makeStoredSession('default', { device })).session; + } + releaseRead(); + const response = await running; + if (transition === 'rebuild') { + expect(response).toMatchObject({ ok: true, data: { text: 'Native value' } }); + expect(current.actions.map((action) => action.command)).toEqual(['get']); + expect(current.appName).toBe('Rebuilt during read'); + expect(ref.session.actions).toEqual([]); + } else { + expect(response).toMatchObject({ + ok: false, + error: { details: { reason: 'session_lifetime_ended' } }, + }); + expect(current.actions).toEqual([]); + } + }, +); + async function withRunner(operation: () => Promise): Promise { return await withAppleRunnerProvider( mockRunAppleRunnerCommand, diff --git a/src/daemon/__tests__/session-store-lifetime.test.ts b/src/daemon/__tests__/session-store-lifetime.test.ts index ef1315fa6d..80ca833207 100644 --- a/src/daemon/__tests__/session-store-lifetime.test.ts +++ b/src/daemon/__tests__/session-store-lifetime.test.ts @@ -149,6 +149,55 @@ test('script writes use the latest matching record and refuse a retired lifetime assert.equal(successor.session.actions.length, 0); }); +test('action recording uses the latest matching record and its scoped journal', async () => { + const store = makeSessionStore(); + const ref = store.publish(ADDRESS, makeSession('default')); + const publicSlot = store.publish('default', makeSession('default')); + const current = store.update(ref, { actions: [] }); + store.recordAction(ref, { + command: 'click', + positionals: ['id="continue"'], + flags: { saveScript: true }, + }); + await store.flushEvents(); + assert.deepEqual( + current.actions.map((action) => action.command), + ['click'], + ); + assert.equal(ref.session.actions.length, 0); + assert.equal(ref.session.scriptPublication, undefined); + assert.equal(current.scriptPublication?.kind, 'authoring'); + assert.equal(publicSlot.session.actions.length, 0); + assert.equal(fs.existsSync(store.resolveEventLogPath('default')), false); + assert.equal(store.readEvents(ADDRESS).events[0]?.session, ADDRESS); +}); + +test.each([{}, { noRecord: true }])( + 'retired action recording refuses before flags or journal writes, flags=%j', + async (flags) => { + const store = makeSessionStore(); + const session = makeSession('default'); + const ref = store.publish(ADDRESS, session); + store.retire(ref); + const successor = store.publish(ADDRESS, session); + assert.throws( + () => + store.recordAction(ref, { + command: 'click', + positionals: ['id="continue"'], + flags: { saveScript: true, ...flags }, + }), + ended, + ); + await store.flushEvents(); + assert.equal(store.requireCurrent(successor), session); + assert.equal(session.actions.length, 0); + assert.equal(session.scriptPublication, undefined); + assert.equal(fs.existsSync(store.resolveEventLogPath(ADDRESS)), false); + assert.equal(fs.existsSync(store.resolveEventLogPath('default')), false); + }, +); + test('repair tombstones follow the scoped address and cannot be written by a retired ref', () => { const store = makeSessionStore(); const ref = store.publish(ADDRESS, makeRepairArmedSession('default')); diff --git a/src/daemon/__tests__/session-store.test.ts b/src/daemon/__tests__/session-store.test.ts index 0e00cfef09..0455a280cb 100644 --- a/src/daemon/__tests__/session-store.test.ts +++ b/src/daemon/__tests__/session-store.test.ts @@ -65,7 +65,7 @@ function recordOpen( flags: RecordActionEntry['flags'] = { platform: 'ios', saveScript: true }, runtime?: RecordActionEntry['runtime'], ): void { - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'open', positionals: ['Settings'], flags, @@ -75,7 +75,7 @@ function recordOpen( } function recordClose(store: SessionStore, session: SessionState): void { - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'close', positionals: [], flags: { platform: 'ios' }, @@ -143,7 +143,7 @@ test('parameterized fill publication writes only the placeholder to target and t const fixture = makeFixture('agent-device-session-log-parameterized-fill-'); const secret = 'publication-only-live-value-1348'; recordOpen(fixture.store, fixture.session); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'fill', positionals: ['id="password"', secret], flags: { platform: 'ios', recordAs: 'PASSWORD' }, @@ -167,7 +167,7 @@ test('parameterized fill publication writes only the placeholder to target and t test('recordAction writes a paged session event log', async () => { const { store, session } = makeFixture('agent-device-session-events-'); recordOpen(store, session, { platform: 'ios' }); - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'click', positionals: ['@14', 'Checkout'], flags: { platform: 'ios' }, @@ -189,7 +189,7 @@ test('recordAction writes a paged session event log', async () => { test('recordAction event log redacts typed text and its length from display positionals', async () => { const { store, session } = makeFixture('agent-device-session-events-redaction-'); - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'fill', positionals: ['@14', 'super-secret-token'], flags: {}, @@ -213,25 +213,25 @@ test('recordAction event log redacts payload-bearing and unknown positionals', a const eventPayload = '{"token":"event-secret-token"}'; const futurePayload = 'future-secret-token'; - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'clipboard', positionals: ['write', clipboardText], flags: {}, result: { action: 'write', textLength: Array.from(clipboardText).length }, }); - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'push', positionals: ['com.example.app', pushPayload], flags: {}, result: { message: 'Pushed notification to com.example.app' }, }); - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'trigger-app-event', positionals: ['checkout', eventPayload], flags: {}, result: { message: 'Triggered app event checkout' }, }); - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'future-command', positionals: ['public-ish', futurePayload], flags: {}, @@ -256,13 +256,13 @@ test('recordAction event log redacts payload-bearing and unknown positionals', a test('recordAction event log omits transformed messages for redacted positionals', async () => { const { store, session } = makeFixture('agent-device-session-events-overlap-redaction-'); - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'future-command', positionals: ['token', 'my-token-123'], flags: {}, result: { message: 'Ran my-token-123 after token' }, }); - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'future-command', positionals: ['arg', 'my-arg-123'], flags: {}, @@ -284,7 +284,7 @@ test('recordAction event log omits transformed messages for redacted positionals test('recordAction event log does not leak short typed text or its length', async () => { const { store, session } = makeFixture('agent-device-session-events-short-text-'); - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'type', positionals: ['e'], flags: {}, @@ -302,7 +302,7 @@ test('recordAction event log does not leak short typed text or its length', asyn test('recordAction event log omits value-bearing selector details', async () => { const { store, session } = makeFixture('agent-device-session-events-selector-redaction-'); - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'click', positionals: ['value=123456'], flags: {}, @@ -329,7 +329,7 @@ test('recordAction event log omits value-bearing selector details', async () => test('recordAction event log rejects malformed provider scroll output', async () => { const { store, session } = makeFixture('agent-device-session-events-malformed-scroll-'); const privateValue = 'provider-private-scroll-value'; - store.recordAction(session, { + store.recordAction(storeSessionForTest(store, session), { command: 'scroll', positionals: [privateValue], flags: {}, @@ -416,7 +416,7 @@ test('writeSessionLog persists open --relaunch in script output', () => { test('writeSessionLog persists record --hide-touches flags in script output', () => { const fixture = makeFixture('agent-device-session-log-record-'); recordOpen(fixture.store, fixture.session); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'record', positionals: ['start', './capture.mp4'], flags: { @@ -435,7 +435,7 @@ test('writeSessionLog persists record --hide-touches flags in script output', () test('writeSessionLog persists screenshot flags in script output', () => { const fixture = makeFixture('agent-device-session-log-screenshot-'); recordOpen(fixture.store, fixture.session); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'screenshot', positionals: ['./page.png'], flags: { platform: 'ios', screenshotFullscreen: true, screenshotScale: 0.3 }, @@ -471,7 +471,7 @@ test('writeSessionLog persists inline open runtime hints in script output', () = test('writeSessionLog persists runtime set hints in script output', () => { const fixture = makeFixture('agent-device-session-log-runtime-'); recordOpen(fixture.store, fixture.session); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'runtime', positionals: ['set'], flags: { @@ -494,7 +494,7 @@ test('writeSessionLog persists runtime set hints in script output', () => { test('writeSessionLog preserves interaction series flags for click/press/swipe', () => { const fixture = makeFixture('agent-device-session-log-series-flags-'); recordOpen(fixture.store, fixture.session); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'click', positionals: ['id="continue_button"'], flags: { @@ -507,7 +507,7 @@ test('writeSessionLog preserves interaction series flags for click/press/swipe', }, result: {}, }); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'press', positionals: ['201', '545'], flags: { @@ -517,7 +517,7 @@ test('writeSessionLog preserves interaction series flags for click/press/swipe', }, result: {}, }); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'swipe', positionals: ['10', '20', '30', '40'], flags: { @@ -528,7 +528,7 @@ test('writeSessionLog preserves interaction series flags for click/press/swipe', }, result: {}, }); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'fill', positionals: ['@e5', 'search'], flags: { @@ -537,7 +537,7 @@ test('writeSessionLog preserves interaction series flags for click/press/swipe', }, result: {}, }); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'gesture', positionals: ['pan', '195', '443', '48', '0', '500'], flags: { @@ -561,19 +561,19 @@ test('writeSessionLog preserves interaction series flags for click/press/swipe', test('writeSessionLog optimizes selector chains and scopes fallback snapshots', () => { const fixture = makeFixture('agent-device-session-log-selectors-'); recordOpen(fixture.store, fixture.session); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'snapshot', positionals: [], flags: { platform: 'ios', snapshotInteractiveOnly: true }, result: {}, }); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'click', positionals: ['@e1'], flags: { platform: 'ios', count: 2 }, result: { selectorChain: ['text="Continue"', 'role=button'], refLabel: 'Continue' }, }); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'longpress', positionals: ['@e3', '800'], flags: { platform: 'ios' }, @@ -583,13 +583,13 @@ test('writeSessionLog optimizes selector chains and scopes fallback snapshots', }, }); // #1783: hover @ref publishes as a portable selector line like click/longpress. - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'hover', positionals: ['@e4~s12'], flags: { platform: 'web', settle: true }, result: { selectorChain: ['text="Second message"', 'role=link'] }, }); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'fill', positionals: ['@e2', 'hello world'], flags: { platform: 'ios', delayMs: 5 }, @@ -631,19 +631,19 @@ test('writeSessionLog preserves significant whitespace and empty string argument launchUrl: 'myapp://dev ', }, ); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'type', positionals: [' leading\ttrailing '], flags: { platform: 'ios' }, result: {}, }); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'fill', positionals: ['@e5', ''], flags: { platform: 'ios' }, result: { refLabel: 'Search field' }, }); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'screenshot', positionals: [' ./screens/final.png '], flags: { platform: 'ios' }, @@ -678,7 +678,7 @@ const SAVE_TARGET_EVIDENCE: TargetAnnotationV1 = { test('writeSessionLog emits the target-v1 annotation immediately before its action line', () => { const fixture = makeFixture('agent-device-session-log-target-evidence-'); recordOpen(fixture.store, fixture.session); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'click', positionals: ['@e12'], flags: { platform: 'ios' }, @@ -700,7 +700,7 @@ test('writeSessionLog emits the target-v1 annotation immediately before its acti test('writeSessionLog never fabricates a target-v1 annotation for actions recorded without evidence', () => { const fixture = makeFixture('agent-device-session-log-no-target-evidence-'); recordOpen(fixture.store, fixture.session); - fixture.store.recordAction(fixture.session, { + fixture.store.recordAction(storeSessionForTest(fixture.store, fixture.session), { command: 'click', positionals: ['@e12'], flags: { platform: 'ios' }, diff --git a/src/daemon/__tests__/wait-runtime.test.ts b/src/daemon/__tests__/wait-runtime.test.ts index 4916c05ee9..ff76e6fa85 100644 --- a/src/daemon/__tests__/wait-runtime.test.ts +++ b/src/daemon/__tests__/wait-runtime.test.ts @@ -148,6 +148,55 @@ function waitRequest(positionals: string[], flags: Record = {}) } as unknown as DaemonRequest; } +test.each(['rebuild', 'retire'] as const)( + 'sleep-only wait records in its admitted lifetime across %s', + async (transition) => { + vi.useFakeTimers(); + try { + const harness = waitRuntimeHarness(); + const sessionStore = makeSessionStore(); + const address = 'cwd:sleep-wait:default'; + const ref = sessionStore.publish(address, makeSession('default', { device: harness.device })); + const running = handleSnapshotCommands({ + req: waitRequest(['100']), + sessionName: address, + logPath: '/tmp/daemon.log', + sessionStore, + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }); + await vi.advanceTimersByTimeAsync(0); + let current = ref.session; + if (transition === 'rebuild') { + current = sessionStore.update(ref, { actions: [], appName: 'Updated during sleep' }); + } else { + sessionStore.retire(ref); + current = sessionStore.publish( + address, + makeSession('default', { device: harness.device }), + ).session; + } + await vi.advanceTimersByTimeAsync(100); + const response = await running; + expect(harness.captureSnapshot).not.toHaveBeenCalled(); + if (transition === 'rebuild') { + expect(response?.ok).toBe(true); + expect(current.actions.map((action) => action.command)).toEqual(['wait']); + expect(ref.session.actions).toEqual([]); + expect(current.appName).toBe('Updated during sleep'); + } else { + expect(response).toMatchObject({ + ok: false, + error: { details: { reason: 'session_lifetime_ended' } }, + }); + expect(current.actions).toEqual([]); + } + } finally { + vi.useRealTimers(); + } + }, +); + async function runWait( positionals: string[], harness: ReturnType, diff --git a/src/daemon/handlers/__tests__/session-reinstall.test.ts b/src/daemon/handlers/__tests__/session-reinstall.test.ts index a48adcfc7e..56a7dbca85 100644 --- a/src/daemon/handlers/__tests__/session-reinstall.test.ts +++ b/src/daemon/handlers/__tests__/session-reinstall.test.ts @@ -278,3 +278,73 @@ test('HarmonyOS reinstall updates the active session identity from the runtime r appName: 'com.example.application', }); }); + +test.each(['rebuild', 'retire'] as const)( + 'held HarmonyOS deployment patches only its admitted lifetime across %s', + async (transition) => { + const store = makeStore(); + const address = 'cwd:held-harmony-deploy:default'; + const session = makeSession('default', { + platform: 'harmonyos', + id: '127.0.0.1:5555', + name: 'Emulator', + kind: 'emulator', + booted: true, + }); + const ref = store.publish(address, session); + const appPath = path.join(mkdtempForTestSync('held-deploy-'), 'Sample.hap'); + fs.writeFileSync(appPath, 'placeholder'); + let start!: () => void; + let release!: () => void; + const started = new Promise((resolve) => { + start = resolve; + }); + const released = new Promise((resolve) => { + release = resolve; + }); + mockDeployAppRuntime.mockImplementationOnce(async () => { + start(); + await released; + return { packageName: 'com.example.updated' }; + }); + const running = handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'reinstall', + positionals: ['com.example.updated', appPath], + flags: {}, + }, + sessionName: address, + sessionStore: store, + logPath: '/tmp/daemon.log', + invoke, + }); + await started; + let current = session; + if (transition === 'rebuild') + current = store.update(ref, { + actions: [], + trace: { outPath: '/latest.trace', startedAt: 1 }, + }); + else { + store.retire(ref); + current = store.publish(address, makeSession('default', session.device)).session; + } + release(); + if (transition === 'rebuild') { + expect(await running).toMatchObject({ ok: true }); + expect(store.requireCurrent(ref).trace).toEqual(current.trace); + expect(store.requireCurrent(ref).appBundleId).toBe('com.example.updated'); + expect(current.actions.map((action) => action.command)).toEqual(['reinstall']); + expect(session.actions).toEqual([]); + } else { + await expect(running).rejects.toMatchObject({ + details: { reason: 'session_lifetime_ended' }, + }); + expect(store.get(address)).toBe(current); + expect(current.appBundleId).toBeUndefined(); + expect(current.actions).toEqual([]); + } + }, +); diff --git a/src/daemon/handlers/__tests__/session-selector-dispatch.test.ts b/src/daemon/handlers/__tests__/session-selector-dispatch.test.ts index 8f9d32fa61..cfdf9d8fd2 100644 --- a/src/daemon/handlers/__tests__/session-selector-dispatch.test.ts +++ b/src/daemon/handlers/__tests__/session-selector-dispatch.test.ts @@ -28,6 +28,8 @@ import type { SessionState } from '../../session-state.ts'; import { handleSessionCommands } from './session-command-harness.ts'; import { refFrameState } from '../../ref-frame.ts'; import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts'; +import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts'; +import { createUnavailableRuntimeFactsForTest } from '../../../__tests__/test-utils/runtime-operation-facts.ts'; const available = Object.freeze({ available: true } as const); const keyboardFamilyDenial = Object.freeze({ @@ -35,6 +37,90 @@ const keyboardFamilyDenial = Object.freeze({ reason: 'owner-capability-missing' as const, }); +test.each(['rebuild', 'retire'] as const)( + 'app-event metadata and action stay in the admitted lifetime across %s', + async (transition) => { + vi.stubEnv('AGENT_DEVICE_IOS_APP_EVENT_URL_TEMPLATE', 'https://example.test/{event}'); + try { + const sessionStore = makeSessionStore(); + const address = 'cwd:held-app-event:default'; + const ref = sessionStore.publish(address, makeIosSession('default')); + const device = ref.session.device; + mockResolveTargetDevice.mockResolvedValue(device); + const owner = localRuntimeOwner(device.platform); + const base = createUnavailableRuntimeFactsForTest(device, owner); + const facts = { + ...base, + operations: { ...base.operations, triggerAppEvent: available, ensureReady: available }, + }; + let start!: () => void; + let release!: () => void; + const started = new Promise((resolve) => { + start = resolve; + }); + const released = new Promise((resolve) => { + release = resolve; + }); + const binding: DeviceBinding = { + device, + owner, + facts, + operations: { + ensureReady: async () => device, + triggerAppEvent: async () => { + start(); + await released; + }, + }, + [Symbol.asyncDispose]: async () => {}, + }; + const running = handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'trigger-app-event', + positionals: ['ready'], + flags: {}, + }, + sessionName: address, + sessionStore, + logPath: '/tmp/daemon.log', + invoke: noopInvoke, + inspectFacts: async () => facts, + bindDevice: async (_device, use) => narrowDeviceBinding(binding, use), + }); + await started; + let current = ref.session; + if (transition === 'rebuild') + current = sessionStore.update(ref, { + actions: [], + trace: { outPath: '/latest.trace', startedAt: 1 }, + }); + else { + sessionStore.retire(ref); + current = sessionStore.publish(address, makeIosSession('default')).session; + } + release(); + if (transition === 'rebuild') { + expect(await running).toMatchObject({ ok: true }); + expect(sessionStore.requireCurrent(ref).trace).toEqual(current.trace); + expect(sessionStore.requireCurrent(ref).appBundleId).toBe('com.apple.mobilesafari'); + expect(current.actions.map((action) => action.command)).toEqual(['trigger-app-event']); + expect(ref.session.actions).toEqual([]); + } else { + await expect(running).rejects.toMatchObject({ + details: { reason: 'session_lifetime_ended' }, + }); + expect(sessionStore.get(address)).toBe(current); + expect(current.appBundleId).toBeUndefined(); + expect(current.actions).toEqual([]); + } + } finally { + vi.unstubAllEnvs(); + } + }, +); + /** Admits every keyboard operation so the ADR 0014 seam runs on real admission, not a rejection. * `keyboardDismiss` is overridable so a test can force the invocation itself to reject, proving * the frame expires before the mutating call runs rather than only after it resolves. */ diff --git a/src/daemon/handlers/react-native.ts b/src/daemon/handlers/react-native.ts index 028fec887b..5711d69785 100644 --- a/src/daemon/handlers/react-native.ts +++ b/src/daemon/handlers/react-native.ts @@ -30,12 +30,12 @@ export async function handleReactNativeCommands( params: InteractionRouteInput, ): Promise { params = bindInteractionSession(params); - const { req, sessionName, sessionStore } = params; + const { req, sessionStore } = params; if (req.command !== PUBLIC_COMMANDS.reactNative) return null; const parsed = parseReactNativeArgs(req.positionals ?? []); if (!parsed.ok) return parsed.response; - const session = sessionStore.get(sessionName); + const session = params.sessionRef ? sessionStore.requireCurrent(params.sessionRef) : undefined; if (!session) return noActiveSessionError(); // R61: admission is the owner's own `tapPoint` fact — the one operation this command executes. // It runs before the observing capture, exactly where the retired capability gate ran, so an @@ -182,7 +182,7 @@ async function executeReactNativeOverlayDismiss( ...successText(formatDismissMessage(verification)), }); return finalizeTouchInteraction({ - session, + ref: params.sessionRef!, sessionStore, command: req.command, positionals: req.positionals ?? [], diff --git a/src/daemon/handlers/record-runtime.ts b/src/daemon/handlers/record-runtime.ts index 12cf18cad1..e7c698bb08 100644 --- a/src/daemon/handlers/record-runtime.ts +++ b/src/daemon/handlers/record-runtime.ts @@ -172,7 +172,7 @@ async function startRecording( const snapshot = adopted.handle.inspect(); recordSessionAction( params.sessionStore, - params.sessionStore.requireCurrent(adoptedRef), + adoptedRef, params.req, params.req.command, buildRecordingStartedAction(snapshot), @@ -258,7 +258,7 @@ async function stopRecording( const completion = stopped.completion; const response = buildRecordingStopResponse(completion); if (stopped.recordsSessionAction) { - recordSessionAction(params.sessionStore, session, params.req, params.req.command, { + recordSessionAction(params.sessionStore, ref, params.req, params.req.command, { action: 'stop', outPath: completion.outPath, ...(completion.clientOutPath diff --git a/src/daemon/handlers/session-app-deployment.ts b/src/daemon/handlers/session-app-deployment.ts index 79768d7158..51e8d79efc 100644 --- a/src/daemon/handlers/session-app-deployment.ts +++ b/src/daemon/handlers/session-app-deployment.ts @@ -12,7 +12,7 @@ import { expireRefFrame } from '../ref-frame.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } 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 { resolvePayloadInput } from '../payload-input.ts'; import { resolveDeployResultTarget } from '../../core/deploy-result-target.ts'; import { withSuccessText } from '@agent-device/kernel/success-text'; @@ -52,7 +52,8 @@ export async function handleAppDeploymentCommand(params: { bindDevice?: BindDeviceRuntime; }): Promise { const { req, command, sessionName, sessionStore } = params; - const session = sessionStore.get(sessionName); + const ref = sessionStore.lookup(sessionName); + const session = ref?.session; const flags = req.flags ?? {}; const guard = requireSessionOrExplicitSelector(command, session, flags); if (guard) return guard; @@ -76,7 +77,7 @@ export async function handleAppDeploymentCommand(params: { const runtime = await requireRuntimeBinding(params.bindDevice)(device, deployAppUse); // ADR 0014: deployment can replace the visible surface. Do this immediately before the // bound operation so an admission failure never discards a still-valid reference frame. - if (session) expireRefFrame(session); + if (ref) expireRefFrame(sessionStore.requireCurrent(ref)); const deployment = await runtime.operations.deployApp({ app: target.app, appPath, @@ -87,11 +88,8 @@ export async function handleAppDeploymentCommand(params: { result, `Installed: ${result.appName ?? resolveDeployResultTarget(result)}`, ); - const sessionForRecord = updateHarmonyDeploymentSession(session, result); - if (sessionForRecord && sessionForRecord !== session) { - sessionStore.set(sessionName, sessionForRecord); - } - recordSessionAction(sessionStore, sessionForRecord, req, command, data); + updateHarmonyDeploymentSession(sessionStore, ref, result); + recordSessionAction(sessionStore, ref, req, command, data); return { ok: true, data }; } finally { if (uploadedArtifactId) cleanupUploadedArtifact(uploadedArtifactId); @@ -102,7 +100,8 @@ export async function handlePushNotificationCommand( params: RuntimeCommandHandlerParams, ): Promise { const { req, sessionName, sessionStore } = params; - const session = sessionStore.get(sessionName); + const ref = sessionStore.lookup(sessionName); + const session = ref?.session; const flags = req.flags ?? {}; const guard = requireSessionOrExplicitSelector('push', session, flags); if (guard) return guard; @@ -131,7 +130,7 @@ export async function handlePushNotificationCommand( // ADR 0014: a notification dispatch may change the visible surface. Keep an admission failure // non-destructive, but expire immediately before dispatch so an attempted provider operation // that fails after crossing the side-effect seam cannot leave stale refs authorized. - if (session) expireRefFrame(session); + if (ref) expireRefFrame(sessionStore.requireCurrent(ref)); const result = await runtime.operations.sendPushNotification({ appId, payload }); const data = isIosFamily(device) ? withSuccessText({ platform: 'ios', bundleId: appId }, `Pushed notification to ${appId}`) @@ -144,7 +143,7 @@ export async function handlePushNotificationCommand( }, `Pushed notification to ${appId}`, ); - recordSessionAction(sessionStore, session, req, 'push', data, { + recordSessionAction(sessionStore, ref, req, 'push', data, { positionals: [appId, payloadArg], }); return { ok: true, data }; @@ -198,15 +197,15 @@ function buildNonIosDeployResult( } function updateHarmonyDeploymentSession( - session: SessionState | undefined, + sessionStore: SessionStore, + ref: SessionRef | undefined, result: DeployResult, -): SessionState | undefined { - if (!session || result.platform !== 'harmonyos' || !result.appId) return session; - return { - ...session, +): void { + if (!ref || result.platform !== 'harmonyos' || !result.appId) return; + sessionStore.update(ref, { appBundleId: result.appId, appName: result.appName ?? result.app, - }; + }); } function resolveDeployTarget( diff --git a/src/daemon/handlers/session-app-source-deployment.ts b/src/daemon/handlers/session-app-source-deployment.ts index c1ee5a432c..013c0bf27a 100644 --- a/src/daemon/handlers/session-app-source-deployment.ts +++ b/src/daemon/handlers/session-app-source-deployment.ts @@ -51,7 +51,8 @@ export async function handleInstallFromSourceDeploymentCommand(params: { bindDevice?: BindDeviceRuntime; }): Promise { const { req, sessionName, sessionStore } = params; - const session = sessionStore.get(sessionName); + const ref = sessionStore.lookup(sessionName); + const session = ref?.session; let resolvedSource: ReturnType | undefined; let materialized: MaterializedAppSource | undefined; let retained: RetainedMaterializedPaths | undefined; @@ -88,14 +89,14 @@ export async function handleInstallFromSourceDeploymentCommand(params: { // ADR 0014 side-effect seam: materialization is request-local, but deployment can // replace the visible app surface. Expire immediately before its sole bound dispatch, // so admission or materialization failures preserve refs while a dispatch rejection does not. - if (session) expireRefFrame(session); + if (ref) expireRefFrame(sessionStore.requireCurrent(ref)); const deployment = await runtime.operations.deployMaterializedApp({ artifact: materialized }); const result = buildInstallFromSourceResult(device, materialized, deployment, retained); const data = withSuccessText( result, `Installed: ${resolveInstallFromSourceResultTarget(result)}`, ); - recordSessionAction(sessionStore, session, req, 'install_source', data, { positionals: [] }); + recordSessionAction(sessionStore, ref, req, 'install_source', data, { positionals: [] }); return { ok: true, data }; } catch (error) { if (retained) { diff --git a/src/daemon/handlers/session-clipboard.ts b/src/daemon/handlers/session-clipboard.ts index 869e618d62..7806b6fe28 100644 --- a/src/daemon/handlers/session-clipboard.ts +++ b/src/daemon/handlers/session-clipboard.ts @@ -143,7 +143,8 @@ export async function handleSessionClipboardCommand(params: { bindDevice?: BindDeviceRuntime; }): Promise { const { req, sessionName, logPath, sessionStore, inspectFacts, bindDevice } = params; - const session = sessionStore.get(sessionName); + const ref = sessionStore.lookup(sessionName); + const session = ref?.session; const flags = req.flags ?? {}; const guard = requireSessionOrExplicitSelector(PUBLIC_COMMANDS.clipboard, session, flags); if (guard) return guard; @@ -163,10 +164,10 @@ export async function handleSessionClipboardCommand(params: { bindDevice, }); if (!bound.ok) return bound.response; - + const current = ref ? sessionStore.requireCurrent(ref) : undefined; const result = await bound.execute( - contextFromFlags(logPath, req.flags, session?.appBundleId, session?.trace?.outPath), + contextFromFlags(logPath, req.flags, current?.appBundleId, current?.trace?.outPath), ); - recordSessionAction(sessionStore, session, req, req.command, result); + recordSessionAction(sessionStore, ref, req, req.command, result); return { ok: true, data: { platform: publicPlatformString(device), ...result } }; } diff --git a/src/daemon/handlers/session-selector-dispatch.ts b/src/daemon/handlers/session-selector-dispatch.ts index c41e8cbf73..275e7e2d29 100644 --- a/src/daemon/handlers/session-selector-dispatch.ts +++ b/src/daemon/handlers/session-selector-dispatch.ts @@ -1,7 +1,7 @@ import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; -import type { SessionState } from '../session-state.ts'; +import type { SessionRef, SessionState } from '../session-state.ts'; import type { SessionStore } from '../session-store.ts'; import { contextFromFlags } from '../context.ts'; import { @@ -46,14 +46,10 @@ async function runSessionOrSelectorDispatch(params: { command: string; positionals: string[]; recordPositionals?: string[]; - deriveNextSession?: ( - session: SessionState, - result: Record | void, - device: DeviceInfo, - ) => Promise | SessionState; + updateSession?: (ref: SessionRef, result: Record | void) => Promise | void; prepare: ( device: DeviceInfo, - session: SessionState | undefined, + ref: SessionRef | undefined, ) => Promise; }): Promise { const { @@ -63,10 +59,11 @@ async function runSessionOrSelectorDispatch(params: { command, positionals, recordPositionals, - deriveNextSession, + updateSession, prepare, } = params; - const session = sessionStore.get(sessionName); + const ref = sessionStore.lookup(sessionName); + const session = ref?.session; const flags = req.flags ?? {}; const guard = requireSessionOrExplicitSelector(command, session, flags); if (guard) return guard; @@ -75,29 +72,26 @@ async function runSessionOrSelectorDispatch(params: { session, flags, }); - const prepared = await prepare(device, session); + if (ref) sessionStore.requireCurrent(ref); + const prepared = await prepare(device, ref); if (!prepared.ok) return prepared.response; // ADR 0014 side-effect seam for session/selector-route leaves (keyboard // dismiss/enter/return, push, trigger-app-event). Expire the frame immediately before the // mutating invocation runs — not after it resolves — when the classification says this // request mutates; keyboard status/get resolve to `preserve` and leave the frame untouched. - if (session && resolveRefFrameEffect(req) === 'may-invalidate') { - expireRefFrame(session); + const current = ref ? sessionStore.requireCurrent(ref) : undefined; + if (current && resolveRefFrameEffect(req) === 'may-invalidate') { + expireRefFrame(current); } const result = await prepared.execute(); - if (session) { - const nextSession = deriveNextSession - ? await deriveNextSession(session, result, device) - : session; - recordSessionAction(sessionStore, nextSession, req, command, result ?? {}, { + if (ref) { + if (updateSession) await updateSession(ref, result); + recordSessionAction(sessionStore, ref, req, command, result ?? {}, { positionals: recordPositionals ?? positionals, }); - if (nextSession !== session) { - sessionStore.set(sessionName, nextSession); - } } return { ok: true, data: result ?? {} }; } @@ -164,7 +158,7 @@ async function runBoundSessionRoute( * lexically. A bare reference would dedupe the wiring and delete the proof with it. */ resolveRuntime: SessionRouteRuntimeResolver; - deriveNextSession?: Parameters[0]['deriveNextSession']; + updateSession?: Parameters[0]['updateSession']; }>, ): Promise { const { req, sessionName, logPath, sessionStore, inspectFacts, bindDevice } = params; @@ -175,8 +169,8 @@ async function runBoundSessionRoute( sessionStore, command: params.command, positionals, - ...(params.deriveNextSession ? { deriveNextSession: params.deriveNextSession } : {}), - prepare: async (device, session) => { + ...(params.updateSession ? { updateSession: params.updateSession } : {}), + prepare: async (device, ref) => { const bound = await params.resolveRuntime({ device, positionals, @@ -185,6 +179,7 @@ async function runBoundSessionRoute( bindDevice, }); if (!bound.ok) return { ok: false, response: bound.response }; + const session = ref ? sessionStore.requireCurrent(ref) : undefined; const dispatchContext = { ...contextFromFlags(logPath, req.flags, session?.appBundleId, session?.trace?.outPath), surface: session?.surface, @@ -217,19 +212,16 @@ export async function handleAppEventCommand( ...params, command: PUBLIC_COMMANDS.triggerAppEvent, resolveRuntime: (runtimeParams) => resolveBoundAppEventRuntime(runtimeParams), - deriveNextSession: async (session, result) => { + updateSession: async (ref, result) => { const eventUrl = typeof result?.eventUrl === 'string' ? result.eventUrl : undefined; - const nextAppBundleId = eventUrl - ? ((await resolveSessionAppBundleIdForTarget( - session.device, - eventUrl, - session.appBundleId, - )) ?? session.appBundleId) - : session.appBundleId; - return { - ...session, - appBundleId: nextAppBundleId, - }; + if (!eventUrl) return; + const session = params.sessionStore.requireCurrent(ref); + const appBundleId = await resolveSessionAppBundleIdForTarget( + session.device, + eventUrl, + session.appBundleId, + ); + if (appBundleId !== undefined) params.sessionStore.update(ref, { appBundleId }); }, }); } diff --git a/src/daemon/handlers/snapshot-alert.ts b/src/daemon/handlers/snapshot-alert.ts index 14c10d4507..858bf36405 100644 --- a/src/daemon/handlers/snapshot-alert.ts +++ b/src/daemon/handlers/snapshot-alert.ts @@ -13,7 +13,7 @@ import { import type { DeviceInfo } from '@agent-device/kernel/device'; import { contextFromFlags } from '../context.ts'; import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; -import type { SessionState } from '../session-state.ts'; +import type { SessionRef, SessionState } from '../session-state.ts'; import { SessionStore } from '../session-store.ts'; import { recordIfSession } from '../snapshot-session.ts'; import { parseTimeout } from '@agent-device/command-registry/parse-timeout'; @@ -28,7 +28,7 @@ type HandleAlertCommandParams = { req: DaemonRequest; logPath: string; sessionStore: SessionStore; - session: SessionState | undefined; + ref: SessionRef | undefined; device: SessionState['device']; inspectFacts?: InspectDeviceRuntimeFacts; bindDevice?: BindDeviceRuntime; @@ -118,7 +118,8 @@ async function executeDismissAlert( export async function handleAlertCommand( params: HandleAlertCommandParams, ): Promise { - const { req, logPath, sessionStore, session, device, inspectFacts, bindDevice } = params; + const { req, logPath, sessionStore, ref, device, inspectFacts, bindDevice } = params; + let session = ref ? sessionStore.requireCurrent(ref) : undefined; const action = normalizeAlertAction(req.positionals?.[0]); const bound = await resolveBoundAlertRuntime({ device, @@ -128,6 +129,7 @@ export async function handleAlertCommand( bindDevice, }); if (!bound.ok) return bound.response; + session = ref ? sessionStore.requireCurrent(ref) : undefined; // ADR 0014 side-effect seam: alert accept/dismiss act on the device; get/wait are read-only. // The alert resolver returns `may-invalidate` only for the acting subactions, so this covers // the accept/dismiss mutations on every owner without touching the read paths. @@ -145,7 +147,7 @@ export async function handleAlertCommand( ...alertTarget(session), execution: runtimeExecutionFromContext(context), }); - recordIfSession(sessionStore, session, req, data); + recordIfSession(sessionStore, ref, req, data); return { ok: true, data }; } diff --git a/src/daemon/handlers/snapshot-settings.ts b/src/daemon/handlers/snapshot-settings.ts index a74a8b6319..e2d9ba450a 100644 --- a/src/daemon/handlers/snapshot-settings.ts +++ b/src/daemon/handlers/snapshot-settings.ts @@ -26,7 +26,7 @@ import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtim import { contextFromFlags } from '../context.ts'; import { SessionStore } from '../session-store.ts'; import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; -import type { SessionState } from '../session-state.ts'; +import type { SessionRef, SessionState } from '../session-state.ts'; import { recordIfSession } from '../snapshot-session.ts'; import { expireRefFrame } from '../ref-frame.ts'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; @@ -62,7 +62,7 @@ type HandleSettingsCommandParams = { req: DaemonRequest; logPath: string; sessionStore: SessionStore; - session: SessionState | undefined; + ref: SessionRef | undefined; device: SessionState['device']; parsed: ParsedSettingsRequest; inspectFacts?: InspectDeviceRuntimeFacts; @@ -217,7 +217,8 @@ async function executeSettingsRead( params: HandleSettingsCommandParams, setting: ReadableSetting, ): Promise { - const { req, logPath, sessionStore, session, device, inspectFacts, bindDevice } = params; + const { req, logPath, sessionStore, ref, device, inspectFacts, bindDevice } = params; + let session = ref ? sessionStore.requireCurrent(ref) : undefined; const refusal = settingsRequestRefusal(device, setting); if (refusal !== undefined) return refusal; const admission = await admitRuntimeUse({ @@ -230,6 +231,8 @@ async function executeSettingsRead( }); if (admission.type === 'response') return admission.response; + session = ref ? sessionStore.requireCurrent(ref) : undefined; + emitDiagnostic({ level: 'debug', phase: 'settings_read', @@ -249,7 +252,7 @@ async function executeSettingsRead( ...payload, ...successText(describeSettingRead(payload)), }; - recordIfSession(sessionStore, session, req, data); + recordIfSession(sessionStore, ref, req, data); return { ok: true, data }; } @@ -257,7 +260,8 @@ async function executeSettingsWrite( params: HandleSettingsCommandParams, parsed: ParsedSettingsArgs, ): Promise { - const { req, logPath, sessionStore, session, device, inspectFacts, bindDevice } = params; + const { req, logPath, sessionStore, ref, device, inspectFacts, bindDevice } = params; + let session = ref ? sessionStore.requireCurrent(ref) : undefined; const { setting, state } = parsed; const refusal = settingsRequestRefusal(device, setting); if (refusal !== undefined) return refusal; @@ -269,8 +273,9 @@ async function executeSettingsWrite( bindDevice, readiness: !session, }); - const appBundleId = settingsWriteAppId(req, parsed, session); if (admission.type === 'response') return admission.response; + session = ref ? sessionStore.requireCurrent(ref) : undefined; + const appBundleId = settingsWriteAppId(req, parsed, session); const writeRefusal = settingsWriteRefusal(parsed, appBundleId); if (writeRefusal !== undefined) return writeRefusal; // ADR 0014 side-effect seam: a settings mutation changes device state; expire the frame before @@ -292,7 +297,7 @@ async function executeSettingsWrite( ), describeSettingWrite(setting, state, appBundleId), ); - recordIfSession(sessionStore, session, req, data); + recordIfSession(sessionStore, ref, req, data); return { ok: true, data }; } diff --git a/src/daemon/handlers/snapshot.ts b/src/daemon/handlers/snapshot.ts index 67d5340d73..62b63cb1f9 100644 --- a/src/daemon/handlers/snapshot.ts +++ b/src/daemon/handlers/snapshot.ts @@ -90,7 +90,11 @@ const SNAPSHOT_COMMAND_HANDLER_IMPLS = { bindDevice, platformResourceCleanup, }) => { - const { session, device } = await resolveSessionDevice(sessionStore, sessionName, req.flags); + const { ref, session, device } = await resolveSessionDevice( + sessionStore, + sessionName, + req.flags, + ); return await withSessionlessRunnerCleanup( session, device, @@ -99,7 +103,7 @@ const SNAPSHOT_COMMAND_HANDLER_IMPLS = { req, logPath, sessionStore, - session, + ref, device, inspectFacts, bindDevice, @@ -119,7 +123,11 @@ const SNAPSHOT_COMMAND_HANDLER_IMPLS = { }) => { const parsedSettings = parseSettingsArgs(req); if (!parsedSettings.ok) return parsedSettings; - const { session, device } = await resolveSessionDevice(sessionStore, sessionName, req.flags); + const { ref, session, device } = await resolveSessionDevice( + sessionStore, + sessionName, + req.flags, + ); return await withSessionlessRunnerCleanup( session, device, @@ -128,7 +136,7 @@ const SNAPSHOT_COMMAND_HANDLER_IMPLS = { req, logPath, sessionStore, - session, + ref, device, parsed: parsedSettings.parsed, inspectFacts, diff --git a/src/daemon/handlers/trace-runtime.ts b/src/daemon/handlers/trace-runtime.ts index 64f804f0b5..01d6486c61 100644 --- a/src/daemon/handlers/trace-runtime.ts +++ b/src/daemon/handlers/trace-runtime.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import type { TraceCommandResult } from '@agent-device/contracts/recording'; import type { SessionStore } from '../session-store.ts'; import type { DaemonRequest, DaemonResponse } from '../daemon-request.ts'; -import type { SessionState } from '../session-state.ts'; +import type { SessionRef } from '../session-state.ts'; import { recordSessionAction } from '../session-action-recorder.ts'; import { errorResponse } from '@agent-device/kernel/contracts'; @@ -17,24 +17,25 @@ export function handleTraceCommand(params: { if (action !== 'start' && action !== 'stop') { return errorResponse('INVALID_ARGS', 'trace requires start|stop'); } - const session = params.sessionStore.get(params.sessionName); - if (!session) return errorResponse('SESSION_NOT_FOUND', 'No active session'); + const ref = params.sessionStore.lookup(params.sessionName); + if (!ref) return errorResponse('SESSION_NOT_FOUND', 'No active session'); return action === 'start' - ? startTrace(params.req, params.sessionStore, session) - : stopTrace(params.req, params.sessionStore, session); + ? startTrace(params.req, params.sessionStore, ref) + : stopTrace(params.req, params.sessionStore, ref); } function startTrace( req: DaemonRequest, sessionStore: SessionStore, - session: SessionState, + ref: SessionRef, ): DaemonResponse { + const session = sessionStore.requireCurrent(ref); if (session.trace) return errorResponse('INVALID_ARGS', 'trace already in progress'); const outPath = expandSessionPath(req.positionals?.[1] ?? sessionStore.defaultTracePath(session)); fs.mkdirSync(path.dirname(outPath), { recursive: true }); fs.appendFileSync(outPath, ''); session.trace = { outPath, startedAt: Date.now() }; - recordSessionAction(sessionStore, session, req, req.command, { action: 'start', outPath }); + recordSessionAction(sessionStore, ref, req, req.command, { action: 'start', outPath }); return { ok: true, data: { trace: 'started', outPath } satisfies TraceCommandResult, @@ -44,12 +45,13 @@ function startTrace( function stopTrace( req: DaemonRequest, sessionStore: SessionStore, - session: SessionState, + ref: SessionRef, ): DaemonResponse { + const session = sessionStore.requireCurrent(ref); if (!session.trace) return errorResponse('INVALID_ARGS', 'no active trace'); const outPath = relocateTraceOutput(session.trace.outPath, req.positionals?.[1]); session.trace = undefined; - recordSessionAction(sessionStore, session, req, req.command, { action: 'stop', outPath }); + recordSessionAction(sessionStore, ref, req, req.command, { action: 'stop', outPath }); const clientOutPath = req.meta?.clientArtifactPaths?.outPath ?? outPath; return { ok: true, diff --git a/src/daemon/interaction/internal/__tests__/interaction-common.test.ts b/src/daemon/interaction/internal/__tests__/interaction-common.test.ts index 446a27be37..9ee05da84a 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-common.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-common.test.ts @@ -78,7 +78,7 @@ test('parameterized fill scrubs backend and nested settle echoes at the response }; const response = finalizeTouchInteraction({ - session, + ref: sessionStore.lookup(session.name)!, sessionStore, command: 'fill', positionals: ['id="password"', secret], @@ -266,7 +266,7 @@ test.each([ }; const response = finalizeTouchInteraction({ - session, + ref: sessionStore.lookup(session.name)!, sessionStore, command: 'fill', positionals: ['id="password"', literal], diff --git a/src/daemon/interaction/internal/find.ts b/src/daemon/interaction/internal/find.ts index b127ad8e70..92c8e04ae7 100644 --- a/src/daemon/interaction/internal/find.ts +++ b/src/daemon/interaction/internal/find.ts @@ -45,7 +45,6 @@ type FindContext = { sessionStore: SessionStore; invoke: DaemonInvokeFn; sessionRef: SessionRef; - session: SessionState; device: SessionState['device']; command: string; locator: FindLocator; @@ -153,7 +152,6 @@ export async function handleFindCommands(params: FindRouteInput): Promise { - const { req, sessionName, sessionStore, session, invoke, command, locator, query, publicFlags } = - ctx; + const { + req, + sessionName, + sessionStore, + sessionRef, + invoke, + command, + locator, + query, + publicFlags, + } = ctx; const response = await invoke({ token: req.token, session: sessionName, @@ -301,7 +308,7 @@ async function handleFindClick(ctx: FindContext, match: ResolvedMatch): Promise< Object.assign(matchData, successText(clickMessage)); recordSessionAction( sessionStore, - session, + sessionRef, req, command, { ref: match.ref, action: 'click', locator, query }, @@ -315,7 +322,7 @@ async function handleFindFill( match: ResolvedMatch, value: string | undefined, ): Promise { - const { req, sessionName, sessionStore, session, invoke, command, publicFlags } = ctx; + const { req, sessionName, sessionStore, sessionRef, invoke, command, publicFlags } = ctx; // `''` is the clear request (#2063); only a MISSING value is an error. if (value === undefined) { return errorResponse('INVALID_ARGS', 'find fill requires text (use "" to clear the field)'); @@ -331,7 +338,7 @@ async function handleFindFill( if (!response.ok) return response; recordSessionAction( sessionStore, - session, + sessionRef, req, command, { ref: match.ref, action: 'fill' }, @@ -352,12 +359,13 @@ async function handleFindType( match: ResolvedMatch, value: string | undefined, ): Promise { - const { req, logPath, session } = ctx; + const { req, logPath } = ctx; if (!value) { return errorResponse('INVALID_ARGS', 'find type requires text'); } const focusResponse = await dispatchFocusForFindMatch(ctx, match); if (!focusResponse.ok) return focusResponse; + const session = ctx.sessionStore.requireCurrent(ctx.sessionRef); // The focus above already crossed the seam; expiry is idempotent, but keep it // explicit at the type dispatch so it does not rely on the focus-first order. expireRefFrame(session); @@ -380,7 +388,7 @@ async function dispatchFocusForFindMatch( ctx: FindContext, match: ResolvedMatch, ): Promise { - const { req, logPath, session } = ctx; + const { req, logPath } = ctx; const coveredResponse = rejectCoveredFindMatch(match, 'be focused'); if (coveredResponse) return coveredResponse; const coords = match.resolvedNode.rect ? centerOfRect(match.resolvedNode.rect) : null; @@ -390,6 +398,7 @@ async function dispatchFocusForFindMatch( // ADR 0014 side-effect seam: mutating find focus/type dispatch the device // command directly (they do not re-enter the interaction leaf), so expire the // frame here before the device op. Pre-seam guards above preserve the frame. + const session = ctx.sessionStore.requireCurrent(ctx.sessionRef); expireRefFrame(session); // R40/R35: the operation came from the handler's ONE action-selected bind; the shared // executor is the single lexical owner of the `focusPoint` call. @@ -420,10 +429,10 @@ function rejectCoveredFindMatch(match: ResolvedMatch, interaction: string): Daem } function recordFindAction(ctx: FindContext, match: ResolvedMatch, action: string): void { - const { req, sessionStore, session, command, publicFlags } = ctx; + const { req, sessionStore, sessionRef, command, publicFlags } = ctx; recordSessionAction( sessionStore, - session, + sessionRef, req, command, { ref: match.ref, action }, diff --git a/src/daemon/interaction/internal/interaction-gesture.ts b/src/daemon/interaction/internal/interaction-gesture.ts index e07d9028f9..59eb620d8a 100644 --- a/src/daemon/interaction/internal/interaction-gesture.ts +++ b/src/daemon/interaction/internal/interaction-gesture.ts @@ -239,7 +239,7 @@ async function dispatchGestureInteraction( responseData.warning = `${existingWarning}${readiness.warning}`; } return finalizeTouchInteraction({ - session, + ref: params.sessionRef!, sessionStore: params.sessionStore, command, actionCommand: command, diff --git a/src/daemon/interaction/internal/interaction-runtime.ts b/src/daemon/interaction/internal/interaction-runtime.ts index cc146829b1..74b9ad5151 100644 --- a/src/daemon/interaction/internal/interaction-runtime.ts +++ b/src/daemon/interaction/internal/interaction-runtime.ts @@ -29,7 +29,7 @@ import { isSessionRecording } from '../../session-script-publication-capability. import { recordTouchVisualizationEvent } from '../../recording-gestures.ts'; import type { SessionStore } from '../../session-store.ts'; import type { DaemonResponse } from '../../daemon-request.ts'; -import type { SessionState } from '../../session-state.ts'; +import type { SessionRef } from '../../session-state.ts'; import type { BoundTouchExecutor } from '../../touch-runtime.ts'; import type { BoundGestureExecutor } from '../../gesture-runtime.ts'; import { NO_ACTIVE_SESSION_MESSAGE } from '@agent-device/kernel/contracts'; @@ -99,16 +99,17 @@ type FinalizeTouchInteractionInput = Omit< Parameters[0], 'operations' > & { - session: SessionState; + ref: SessionRef; sessionStore: SessionStore; }; export function finalizeTouchInteraction(params: FinalizeTouchInteractionInput): DaemonResponse { - const { session, sessionStore, ...finalization } = params; + const { ref, sessionStore, ...finalization } = params; + const session = sessionStore.requireCurrent(ref); return finalizeInteraction({ ...finalization, operations: { - recordAction: sessionStore.recordAction.bind(sessionStore, session), + recordAction: sessionStore.recordAction.bind(sessionStore, ref), markDeferredOutcome: (mark) => markDeferredInteractionOutcome({ session, ...mark }), isSessionRecording: isSessionRecording.bind(null, session), recordGestureVisualization: recordTouchVisualizationEvent.bind(null, session), diff --git a/src/daemon/interaction/internal/interaction-touch-direct-ios.ts b/src/daemon/interaction/internal/interaction-touch-direct-ios.ts index 46835feae8..96564df057 100644 --- a/src/daemon/interaction/internal/interaction-touch-direct-ios.ts +++ b/src/daemon/interaction/internal/interaction-touch-direct-ios.ts @@ -72,7 +72,7 @@ export async function dispatchDirectIosSelectorTap( }, }); return finalizeTouchInteraction({ - session, + ref: handlerParams.sessionRef!, sessionStore: handlerParams.sessionStore, command: handlerParams.req.command, positionals: handlerParams.req.positionals ?? [], @@ -86,7 +86,6 @@ export async function dispatchDirectIosSelectorTap( const corroboratedResponse = await buildDirectIosCorroboratedResponse({ error, handlerParams, - session, extra, positionals: [], actionStartedAt, @@ -118,12 +117,11 @@ async function buildDirectIosCorroboratedResponse(params: { handlerParams: InteractionRouteInput & { captureSnapshotForSession: CaptureSnapshotForSession; }; - session: SessionState; extra: Record; positionals: string[]; actionStartedAt: number; }): Promise { - const { error, handlerParams, session, extra, positionals, actionStartedAt } = params; + const { error, handlerParams, extra, positionals, actionStartedAt } = params; const corroboration = await corroborateIosTapFailure({ error, command: handlerParams.req.command, @@ -143,7 +141,7 @@ async function buildDirectIosCorroboratedResponse(params: { extra, }); return finalizeTouchInteraction({ - session, + ref: handlerParams.sessionRef!, sessionStore: handlerParams.sessionStore, command: handlerParams.req.command, positionals: handlerParams.req.positionals ?? positionals, diff --git a/src/daemon/interaction/internal/interaction-touch-runtime.ts b/src/daemon/interaction/internal/interaction-touch-runtime.ts index aa7f4c400a..27ed55f7b1 100644 --- a/src/daemon/interaction/internal/interaction-touch-runtime.ts +++ b/src/daemon/interaction/internal/interaction-touch-runtime.ts @@ -108,7 +108,7 @@ export async function dispatchRuntimeInteraction< responseData.warning = warning; } return finalizeTouchInteraction({ - session, + ref: params.sessionRef, sessionStore: params.sessionStore, command: params.req.command, positionals: params.req.positionals ?? [], @@ -176,7 +176,7 @@ async function buildRuntimeIosCorroboratedResponse(params: { extra: params.extra, }); return finalizeTouchInteraction({ - session: params.session, + ref: params.handlerParams.sessionRef!, sessionStore: params.handlerParams.sessionStore, command: params.handlerParams.req.command, positionals: params.handlerParams.req.positionals ?? [], diff --git a/src/daemon/interaction/internal/interaction.ts b/src/daemon/interaction/internal/interaction.ts index 9906d71224..f39fa054f5 100644 --- a/src/daemon/interaction/internal/interaction.ts +++ b/src/daemon/interaction/internal/interaction.ts @@ -168,7 +168,7 @@ async function runTypeTextViaRuntime( const responseData: Record = { ...result }; appendTypeReadinessWarnings(responseData, recordingRecoveryWarning, readiness); return finalizeTouchInteraction({ - session, + ref: params.sessionRef!, sessionStore, command: req.command, positionals: req.positionals ?? [], diff --git a/src/daemon/request-generic-dispatch.ts b/src/daemon/request-generic-dispatch.ts index a3eaccfafe..37bd6db269 100644 --- a/src/daemon/request-generic-dispatch.ts +++ b/src/daemon/request-generic-dispatch.ts @@ -61,7 +61,7 @@ export type ResolvedGenericExecution = export async function dispatchGenericCommand(params: { req: DaemonRequest; - session: SessionState; + ref: SessionRef; sessionName: string; logPath: string; sessionStore: SessionStore; @@ -74,8 +74,8 @@ export async function dispatchGenericCommand(params: { recordedRequest?: RecordedGenericRequest; androidObservation?: AndroidObservationAdapter; }): Promise { - const { req, session, logPath, sessionStore, contextFromFlags } = params; - const sessionRef = sessionStore.lookup(params.sessionName); + const { req, ref: sessionRef, logPath, sessionStore, contextFromFlags } = params; + const session = sessionStore.requireCurrent(sessionRef); const platformCommand = req.command; const commandReadiness = await ensureGenericCommandReady( @@ -112,9 +112,10 @@ export async function dispatchGenericCommand(params: { }; const actionStartedAt = Date.now(); + const current = sessionStore.requireCurrent(sessionRef); const dispatchContext = { - ...contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath), - surface: session.surface, + ...contextFromFlags(req.flags, current.appBundleId, current.trace?.outPath), + surface: current.surface, }; // ADR 0014 side-effect seam for generic-route leaves (back/home/rotate/scroll/ // tv-remote/app-switcher/viewport/focus). Effect classification @@ -122,10 +123,10 @@ export async function dispatchGenericCommand(params: { // before dispatching so a later ref cannot reuse it. Read-only generic leaves // (screenshot) are classified `preserve` and leave the frame untouched. if (resolveRefFrameEffect(req) === 'may-invalidate') { - expireRefFrame(session); + expireRefFrame(current); } const data = await params.executePlatformCommand({ - session, + session: current, sessionName: params.sessionName, logPath, command: platformCommand, @@ -136,7 +137,7 @@ export async function dispatchGenericCommand(params: { }); return await finalizeGenericCommand({ req, - session, + ref: sessionRef, sessionStore, command: platformCommand, resolvedPositionals, @@ -159,7 +160,7 @@ export async function dispatchGenericCommand(params: { */ async function finalizeGenericCommand(params: { req: DaemonRequest; - session: SessionState; + ref: SessionRef; sessionStore: SessionStore; command: string; resolvedPositionals: string[]; @@ -170,9 +171,9 @@ async function finalizeGenericCommand(params: { observeSettle?: () => Promise; androidObservation?: AndroidObservationAdapter; }): Promise { - const { req, session, sessionStore, command } = params; + const { req, ref, sessionStore, command } = params; const postflightReadiness = await ensureNoAndroidBlockingDialogReady( - session, + sessionStore.requireCurrent(ref), command, params.androidObservation, 'after-command', @@ -181,7 +182,7 @@ async function finalizeGenericCommand(params: { let data = withReadinessWarnings(params.data, params.readinessWarnings); recordVisualizationAndAction({ - session, + ref, sessionStore, command, resolvedPositionals: params.resolvedPositionals, @@ -194,7 +195,7 @@ async function finalizeGenericCommand(params: { }); markDeferredInteractionOutcome({ - session, + session: sessionStore.requireCurrent(ref), command, positionals: params.resolvedPositionals, flags: req.flags, @@ -332,7 +333,7 @@ async function ensureGenericCommandReady( } function recordVisualizationAndAction(params: { - session: SessionState; + ref: SessionRef; sessionStore: SessionStore; command: string; resolvedPositionals: string[]; @@ -344,7 +345,7 @@ function recordVisualizationAndAction(params: { clientArtifactPaths: Record | undefined; }): void { const { - session, + ref, sessionStore, command, resolvedPositionals, @@ -355,6 +356,7 @@ function recordVisualizationAndAction(params: { flags, clientArtifactPaths, } = params; + const session = sessionStore.requireCurrent(ref); const visualizationData = augmentScrollVisualizationResult( session, command, @@ -370,7 +372,7 @@ function recordVisualizationAndAction(params: { actionStartedAt, actionFinishedAt, ); - sessionStore.recordAction(session, { + sessionStore.recordAction(ref, { command, positionals: recorded.positionals, flags: recorded.flags, diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index b057bbca74..52da4cdef3 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -493,10 +493,11 @@ async function dispatchGenericForLockedScope(params: { androidObservation: AndroidObservationAdapter; }): Promise { const { lockedScope, logPath, sessionStore, androidObservation } = params; - const session = sessionStore.get(lockedScope.sessionName); - if (!session) { + const ref = sessionStore.lookup(lockedScope.sessionName); + if (!ref) { return noActiveSessionError(); } + const session = sessionStore.requireCurrent(ref); const runtimeExecution = await resolveGenericRuntimeExecution({ req: lockedScope.req, @@ -516,7 +517,7 @@ async function dispatchGenericForLockedScope(params: { const { dispatchGenericCommand } = await loadGenericRequestHandlerModule(); const dispatchResponse = await dispatchGenericCommand({ req: lockedScope.req, - session, + ref, sessionName: lockedScope.sessionName, logPath, sessionStore, diff --git a/src/daemon/selector-recording.ts b/src/daemon/selector-recording.ts index b03679e242..e7e141313a 100644 --- a/src/daemon/selector-recording.ts +++ b/src/daemon/selector-recording.ts @@ -3,6 +3,7 @@ import type { SnapshotNode } from '@agent-device/kernel/snapshot'; import type { FindReadResult } from '@agent-device/contracts/interaction'; import { stripAndroidSystemChromeProvenanceFromNode } from '@agent-device/contracts/android-system-chrome'; import { SessionStore } from './session-store.ts'; +import type { SessionRef } from './session-state.ts'; import { isInteractiveObservation } from './session-action-recorder.ts'; import { isSessionRecording } from './session-script-publication-capability.ts'; import { @@ -127,7 +128,7 @@ export function stripSelectorChain>(result: T) export function recordIfSession( sessionStore: SessionStore, - sessionName: string, + ref: SessionRef | undefined, req: DaemonRequest, result: Record, /** ADR 0012 decision 3: record-time input for the `target-v1` annotation. */ @@ -135,13 +136,13 @@ export function recordIfSession( /** #1349: `landmark` for wait's existence-semantics evidence; defaults to `action`. */ evidenceMode?: TargetEvidenceMode, ): void { - const session = sessionStore.get(sessionName); - if (!session) return; + if (!ref) return; + const session = sessionStore.requireCurrent(ref); const targetEvidence = isSessionRecording(session) && recordedTarget ? computeTargetEvidence(recordedTarget, { mode: evidenceMode }) : undefined; - sessionStore.recordAction(session, { + sessionStore.recordAction(ref, { command: req.command, positionals: req.positionals ?? [], flags: req.flags ?? {}, diff --git a/src/daemon/selector-runtime.ts b/src/daemon/selector-runtime.ts index 4ad7685b04..11ff2fdc99 100644 --- a/src/daemon/selector-runtime.ts +++ b/src/daemon/selector-runtime.ts @@ -66,7 +66,7 @@ export async function dispatchFindReadOnlyViaRuntime( }); recordIfSession( params.sessionStore, - params.sessionName, + resolvedRuntime.ref, req, buildFindRecordResult(result, action), ); @@ -168,7 +168,7 @@ export async function dispatchGetViaRuntime( }); recordIfSession( params.sessionStore, - params.sessionName, + resolvedRuntime.ref, req, buildGetRecordResult(result, sub), { @@ -236,7 +236,7 @@ export async function dispatchIsViaRuntime( }); const recordedTarget = readRecordedResolutionTarget(result); const strippedResult = stripResolutionPayload(result); - recordIfSession(params.sessionStore, params.sessionName, req, strippedResult, recordedTarget); + recordIfSession(params.sessionStore, resolvedRuntime.ref, req, strippedResult, recordedTarget); return stripSelectorChain(strippedResult); }); return withCaptureDisclosures({ diff --git a/src/daemon/session-action-recorder.ts b/src/daemon/session-action-recorder.ts index 3335c3bc2a..3300d2d7b0 100644 --- a/src/daemon/session-action-recorder.ts +++ b/src/daemon/session-action-recorder.ts @@ -3,7 +3,7 @@ import type { CommandFlags } from '@agent-device/contracts/command'; import { recordedFlagKeys } from '@agent-device/command-registry/flag-registry'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import type { DaemonRequest } from './daemon-request.ts'; -import type { SessionRuntimeHints, SessionState } from './session-state.ts'; +import type { SessionRef, SessionRuntimeHints, SessionState } from './session-state.ts'; import { applyRecordedSaveScriptFlags } from './session-script-publication-capability.ts'; import { repairSessionBoundary } from './session-replay-transaction.ts'; import type { MultiTargetAnnotationV1, TargetAnnotationV1 } from '@agent-device/contracts/replay'; @@ -98,24 +98,24 @@ export function recordActionEntry( return action; } -type SessionActionStore = { recordAction(session: SessionState, entry: RecordActionEntry): void }; +type SessionActionStore = { recordAction(ref: SessionRef, entry: RecordActionEntry): void }; /** - * Record a session action if a session is active. No-op when session is undefined. + * Record an action in its admitted lifetime. No-op when no session was admitted. * * By default the recorded positionals/flags mirror the request; pass `overrides` to * record a different set (e.g. resolved positionals or stripped public flags). */ export function recordSessionAction( sessionStore: SessionActionStore, - session: SessionState | undefined, + ref: SessionRef | undefined, req: DaemonRequest, command: string, result: Record | undefined, overrides?: { positionals?: string[]; flags?: CommandFlags }, ): void { - if (!session) return; - sessionStore.recordAction(session, { + if (!ref) return; + sessionStore.recordAction(ref, { command, positionals: overrides?.positionals ?? req.positionals ?? [], flags: overrides?.flags ?? ((req.flags ?? {}) as CommandFlags), diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts index b5f11e6f11..d7d41a58ef 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts @@ -618,15 +618,15 @@ for (const transition of ['rebuild', 'retire'] as const) { makeAndroidEmulator(`emulator-foreground-${transition}`), ); const record = store.recordAction.bind(store); - vi.spyOn(store, 'recordAction').mockImplementationOnce((session, entry) => { - record(session, entry); + vi.spyOn(store, 'recordAction').mockImplementationOnce((recordedRef, entry) => { + record(recordedRef, entry); const ref = store.lookup('cwd:held-open:default')!; queueMicrotask(() => { if (transition === 'rebuild') { store.update(ref, { recordOnlySession: true }); } else { store.retire(ref); - store.publish(ref.address, session); + store.publish(ref.address, recordedRef.session); } mockInspectDeviceRuntimeFacts.mockClear(); }); diff --git a/src/daemon/session-lifecycle/internal/session-close-script.ts b/src/daemon/session-lifecycle/internal/session-close-script.ts index ec64319b86..ea8c3a69a6 100644 --- a/src/daemon/session-lifecycle/internal/session-close-script.ts +++ b/src/daemon/session-lifecycle/internal/session-close-script.ts @@ -18,12 +18,9 @@ export type RepairCloseCommit = | { kind: 'aborted' } | { kind: 'failed'; error: AppError }; -function recordCloseAction( - sessionStore: SessionStore, - session: SessionState, - req: DaemonRequest, -): void { - sessionStore.recordAction(session, { +function recordCloseAction(sessionStore: SessionStore, ref: SessionRef, req: DaemonRequest): void { + const session = sessionStore.requireCurrent(ref); + sessionStore.recordAction(ref, { command: 'close', positionals: req.positionals ?? [], flags: (req.flags ?? {}) as CommandFlags, @@ -40,7 +37,7 @@ export function commitRepairScriptBeforeClose( if (!isRepairArmedSession(session)) return { kind: 'not-armed' }; const actionsBeforeClose = session.actions.length; - recordCloseAction(sessionStore, session, req); + recordCloseAction(sessionStore, ref, req); const alreadyPublished = isSessionScriptPublished(session); const result = sessionStore.writeSessionLog(ref, { force: effectiveWriteForce(session, req.flags?.force), @@ -85,7 +82,7 @@ export function finalizeOrdinaryCloseScript(params: { const { req, ref, sessionStore, platformCloseError } = params; const session = sessionStore.requireCurrent(ref); if (!platformCloseError) { - recordCloseAction(sessionStore, session, req); + recordCloseAction(sessionStore, ref, req); } // The recorded close action already armed target/force through the recorder's flag ingress. // On a platform-close failure that action was never recorded, so the log still publishes to the diff --git a/src/daemon/session-lifecycle/internal/session-open-execution.ts b/src/daemon/session-lifecycle/internal/session-open-execution.ts index 980f3a54ca..ef8a65fbc0 100644 --- a/src/daemon/session-lifecycle/internal/session-open-execution.ts +++ b/src/daemon/session-lifecycle/internal/session-open-execution.ts @@ -330,7 +330,7 @@ export async function completeOpenCommand(params: { saveScriptRequested: Boolean(req.flags?.saveScript), responseData: openResult, }); - sessionStore.recordAction(nextSession, { + sessionStore.recordAction(nextRef, { command: 'open', positionals: openPositionals, flags: req.flags ?? {}, diff --git a/src/daemon/session-observability/internal/session-perf-runtime.ts b/src/daemon/session-observability/internal/session-perf-runtime.ts index f8761f6231..328352681b 100644 --- a/src/daemon/session-observability/internal/session-perf-runtime.ts +++ b/src/daemon/session-observability/internal/session-perf-runtime.ts @@ -75,7 +75,7 @@ export async function handlePerfRuntimeCommand( const plan = resolvePerfRuntimePlan(request); if (plan.kind === 'capture-stop') { return recordSuccessfulPerfResponse( - params, + bound, await stopPerfCapture(bound, session, plan.request), ); } @@ -91,7 +91,7 @@ export async function handlePerfRuntimeCommand( ); } return recordSuccessfulPerfResponse( - params, + bound, await executeAdmittedPerfPlan(bound, session, admitted), ); } catch (error) { @@ -100,14 +100,13 @@ export async function handlePerfRuntimeCommand( } function recordSuccessfulPerfResponse( - params: PerfRuntimeHandlerParams, + params: PerfRuntimeHandlerParams & { ref: SessionRef }, response: DaemonResponse, ): DaemonResponse { if (!response.ok) return response; - const session = params.sessionStore.get(params.sessionName); recordSessionAction( params.sessionStore, - session, + params.ref, params.req, 'perf', isDataRecord(response.data) ? { ...response.data } : {}, diff --git a/src/daemon/session-store.ts b/src/daemon/session-store.ts index e6818d4071..4cdc55e0de 100644 --- a/src/daemon/session-store.ts +++ b/src/daemon/session-store.ts @@ -207,11 +207,10 @@ export class SessionStore { return this.runtimeHints.delete(name); } - recordAction(session: SessionState, entry: RecordActionEntry): void { - const action = recordActionEntry(session, entry); + recordAction(ref: SessionRef, entry: RecordActionEntry): void { + const action = recordActionEntry(this.requireCurrent(ref), entry); if (action) { - const sessionName = this.resolveStoredSessionName(session); - appendActionEvent(this.resolveEventLogPath(sessionName), sessionName, action); + appendActionEvent(this.resolveEventLogPath(ref.address), ref.address, action); } } @@ -275,7 +274,7 @@ export class SessionStore { finalizeRepairTeardown(ref: SessionRef): void { const session = this.resolveCurrent(ref); if (!session) return; - this.recordRepairFinalizeCloseIfCommitting(session); + this.recordRepairFinalizeCloseIfCommitting(ref); // #1258: no live request here (idle-reap/daemon-shutdown teardown), so // the only source of `force` is whatever was persisted on the session at // arm time. @@ -302,10 +301,11 @@ export class SessionStore { * (incomplete) transaction's write is a no-op regardless, so there is * nothing to make self-contained. */ - private recordRepairFinalizeCloseIfCommitting(session: SessionState): void { + private recordRepairFinalizeCloseIfCommitting(ref: SessionRef): void { + const session = this.requireCurrent(ref); const state = session.scriptPublication ?? NO_SCRIPT_PUBLICATION; if (!isRepairCommittable(state)) return; - this.recordAction(session, { + this.recordAction(ref, { command: 'close', positionals: [], flags: {}, @@ -487,15 +487,4 @@ export class SessionStore { resolveEventLogPath(sessionName: string): string { return resolveSessionEventLogPath(this.resolveSessionDir(sessionName)); } - - /** - * 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, entry] of this.sessions) { - if (entry.current === session) return name; - } - return session.name; - } } diff --git a/src/daemon/snapshot-command-runtime.ts b/src/daemon/snapshot-command-runtime.ts index 6ec78b4bfb..76199e7b96 100644 --- a/src/daemon/snapshot-command-runtime.ts +++ b/src/daemon/snapshot-command-runtime.ts @@ -102,7 +102,7 @@ export async function dispatchSnapshotRuntimeCommand( req, sessionName, sessionStore, - session: current, + ref: sessions.getRef(), result: result.record, }); const data = applyRecoveredWarningLatch({ @@ -266,12 +266,11 @@ function recordSnapshotRuntimeAction(params: { req: DaemonRequest; sessionName: string; sessionStore: SessionStore; - session: SessionState | undefined; + ref: SessionRef | undefined; result: SnapshotRuntimeRecord; }): void { - const session = params.session; - if (!session) return; - params.sessionStore.recordAction(session, { + if (!params.ref) return; + params.sessionStore.recordAction(params.ref, { command: params.req.command, positionals: params.req.positionals ?? [], flags: params.req.flags ?? {}, diff --git a/src/daemon/snapshot-session.ts b/src/daemon/snapshot-session.ts index ea80c1bf7d..74f1a2e08a 100644 --- a/src/daemon/snapshot-session.ts +++ b/src/daemon/snapshot-session.ts @@ -1,7 +1,7 @@ import { resolveTargetDevice } from '@agent-device/device-selection/dispatch-resolve'; import type { PlatformResourceCleanup } from './platform-resource-cleanup.ts'; import type { DaemonRequest } from './daemon-request.ts'; -import type { SessionState } from './session-state.ts'; +import type { SessionRef, SessionState } from './session-state.ts'; import type { SessionScope } from '@agent-device/contracts/session'; import { isActiveProviderDevice } from './provider-device-admission.ts'; import { SessionStore } from './session-store.ts'; @@ -40,12 +40,12 @@ export async function withSessionlessRunnerCleanup( export function recordIfSession( sessionStore: SessionStore, - session: SessionState | undefined, + ref: SessionRef | undefined, req: DaemonRequest, result: Record, ): void { - if (!session) return; - sessionStore.recordAction(session, { + if (!ref) return; + sessionStore.recordAction(ref, { command: req.command, positionals: req.positionals ?? [], flags: req.flags ?? {}, diff --git a/src/daemon/wait-runtime.ts b/src/daemon/wait-runtime.ts index 035b80aa42..9cd1281cd7 100644 --- a/src/daemon/wait-runtime.ts +++ b/src/daemon/wait-runtime.ts @@ -171,7 +171,7 @@ async function executeWaitRequest( const recordedTarget = readRecordedResolutionTarget(result); recordIfSession( sessionStore, - sessionName, + params.ref, req, stripResolutionPayload(result), recordedTarget, From c7da1e81e5c250a66f55c7fc27c80d469e1d2db3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 12:08:49 +0200 Subject: [PATCH 42/44] fix: make retired touch observations consistent --- .../selector-capture-runtime.test.ts | 4 -- .../interaction-touch-response.test.ts | 54 ++++++++++++++++++- .../internal/find-target-capture.ts | 4 +- src/daemon/interaction/internal/find.ts | 1 - .../internal/interaction-touch-press.ts | 1 - .../internal/interaction-touch-response.ts | 19 ++++--- src/daemon/selector-capture-runtime.ts | 1 - src/daemon/selector-runtime-backend.ts | 1 - .../session-open-execution-runtime.test.ts | 2 +- 9 files changed, 64 insertions(+), 23 deletions(-) diff --git a/src/daemon/__tests__/selector-capture-runtime.test.ts b/src/daemon/__tests__/selector-capture-runtime.test.ts index 9230151055..5173d75ad5 100644 --- a/src/daemon/__tests__/selector-capture-runtime.test.ts +++ b/src/daemon/__tests__/selector-capture-runtime.test.ts @@ -54,7 +54,6 @@ test('selector capture cache is keyed by scoped presentation options', async () const runtime = createSelectorCaptureRuntime({ ref: sessionStore.lookup(sessionName), device: session.device, - session, sessionStore, sessionName, capture: boundCapture, @@ -228,7 +227,6 @@ function proofRuntime(params: { const runtime = createSelectorCaptureRuntime({ ref: sessionStore.lookup(params.sessionName), device: session.device, - session, sessionStore, sessionName: params.sessionName, consumedSnapshot, @@ -322,7 +320,6 @@ function makeCaptureRuntime(sessionName: string) { const runtime = createSelectorCaptureRuntime({ ref: sessionStore.lookup(sessionName), device: session.device, - session, sessionStore, sessionName, capture: boundCapture, @@ -356,7 +353,6 @@ test('a held selector capture updates the matching rebuilt record without restor const runtime = createSelectorCaptureRuntime({ ref, device: ref.session.device, - session: ref.session, sessionStore, sessionName: address, capture: boundCapture, diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-response.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-response.test.ts index c72da0e3eb..0dbc8a9b27 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-response.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-response.test.ts @@ -101,6 +101,59 @@ function selectorResult(readiness?: { polls: number; waitedMs: number }): PressC const WAITED_SELECTOR_RESULT = selectorResult({ polls: 3, waitedMs: 400 }); +test.each([false, true])('selector-touch observation after retirement=%s', async (retired) => { + const sessionStore = makeSessionStore(); + const address = 'cwd:selector-touch-response:default'; + const session = makeSession('default'); + session.snapshot = { + nodes: attachRefs([{ index: 0, type: 'Button', label: 'Continue' }]), + createdAt: Date.now(), + backend: 'xctest', + }; + session.snapshotGeneration = 3; + const ref = sessionStore.publish(address, session); + const result: PressCommandResult = { + ...selectorResult(), + settle: { + settled: true, + waitedMs: 25, + captures: 2, + quietMs: 25, + timeoutMs: 2000, + diff: { + summary: { additions: 1, removals: 0, unchanged: 0 }, + lines: [{ kind: 'added', text: 'Continue', ref: 'e1' }], + }, + }, + }; + let successor; + if (retired) { + sessionStore.retire(ref); + successor = sessionStore.publish(address, makeSession('default')); + } + const payloads = await buildTargetedTouchResponsePayloads({ + params: { + req: { token: 't', command: 'press', positionals: ['@e1'], session: 'default' }, + sessionName: address, + sessionRef: ref, + sessionStore, + contextFromFlags, + captureSnapshotForSession: vi.fn(), + }, + result, + staleRefsWarning: undefined, + extra: {}, + }); + if (retired) { + expect(payloads.responseData.settle).toBeUndefined(); + expect(payloads.result.settle).toBeUndefined(); + expect(sessionStore.lookup(address)).toEqual(successor); + expect(successor?.session.snapshot).toBeUndefined(); + } else { + expect(payloads.responseData.settle).toMatchObject({ refsGeneration: 3, settled: true }); + } +}); + test.each([false, true])( 'point-touch publication after a held frame probe, retired=%s', async (retired) => { @@ -157,7 +210,6 @@ test.each([false, true])( return sessionStore.requireCurrent(ref).snapshot!; }, }, - session, result, staleRefsWarning: undefined, extra: {}, diff --git a/src/daemon/interaction/internal/find-target-capture.ts b/src/daemon/interaction/internal/find-target-capture.ts index 9160a93e85..3109598692 100644 --- a/src/daemon/interaction/internal/find-target-capture.ts +++ b/src/daemon/interaction/internal/find-target-capture.ts @@ -21,7 +21,6 @@ export function createFindTargetCapture( params: Readonly<{ ref: SessionRef; device: SessionState['device']; - session: SessionState; req: DaemonRequest; logPath: string; locator: FindLocator; @@ -36,11 +35,10 @@ export function createFindTargetCapture( captureProof: RequestCaptureProof; }>, ): () => Promise { - const { device, session, req, logPath, locator, query, sessionStore, sessionName } = params; + const { device, req, logPath, locator, query, sessionStore, sessionName } = params; const captureRuntime = createSelectorCaptureRuntime({ ref: params.ref, device, - session, sessionStore, sessionName, req, diff --git a/src/daemon/interaction/internal/find.ts b/src/daemon/interaction/internal/find.ts index 92c8e04ae7..1057e97804 100644 --- a/src/daemon/interaction/internal/find.ts +++ b/src/daemon/interaction/internal/find.ts @@ -134,7 +134,6 @@ export async function handleFindCommands(params: FindRouteInput): Promise; extra: Record; }): Promise { const { params: handlerParams, result, publicData, extra } = params; - const referenceFrame = + const probedFrame = result.kind === 'point' ? await resolveDirectTouchReferenceFrameSafely({ ref: handlerParams.sessionRef!, @@ -305,13 +303,14 @@ export async function buildTargetedTouchResponsePayloads(params: { captureSnapshotForSession: handlerParams.captureSnapshotForSession, observation: handlerParams.androidObservation, }) - : readSnapshotNodesReferenceFrame( - handlerParams.sessionStore.requireCurrent(handlerParams.sessionRef!).snapshot?.nodes ?? - [], - ); - const currentResult = handlerParams.sessionStore.resolveCurrent(handlerParams.sessionRef!) - ? result - : { ...result, settle: undefined }; + : undefined; + const current = handlerParams.sessionStore.resolveCurrent(handlerParams.sessionRef!); + const referenceFrame = current + ? result.kind === 'point' + ? probedFrame + : readSnapshotNodesReferenceFrame(current.snapshot?.nodes ?? []) + : undefined; + const currentResult = current ? result : { ...result, settle: undefined }; return buildInteractionResponseData({ source: { kind: 'runtime', result: currentResult, publicData }, referenceFrame, diff --git a/src/daemon/selector-capture-runtime.ts b/src/daemon/selector-capture-runtime.ts index 508ffca20b..6bd2a92089 100644 --- a/src/daemon/selector-capture-runtime.ts +++ b/src/daemon/selector-capture-runtime.ts @@ -26,7 +26,6 @@ const SELECTOR_CAPTURE_CACHE_TTL_MS = 750; export type SelectorCaptureRuntimeParams = { ref: SessionRef | undefined; device: SessionState['device']; - session: SessionState | undefined; sessionStore: SessionStore; sessionName: string; req: DaemonRequest; diff --git a/src/daemon/selector-runtime-backend.ts b/src/daemon/selector-runtime-backend.ts index 1e87c6c47f..24ad3eeb8d 100644 --- a/src/daemon/selector-runtime-backend.ts +++ b/src/daemon/selector-runtime-backend.ts @@ -181,7 +181,6 @@ function createSelectorBackend(params: SelectorRuntimeDeviceParams): AgentDevice : createSelectorCaptureRuntime({ ref: params.ref, device, - session, sessionStore, sessionName, req, diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts index d7d41a58ef..f5d2f37a62 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts @@ -605,7 +605,7 @@ test('fresh open does not publish when its artifact directory cannot be created' fs.writeFileSync(path.dirname(store.resolveSessionDir('cwd:held-open:default')), 'blocked'); await expect( invokeHeldOpen(store, { runtime: { metroHost: 'new-host', metroPort: 9000 } }), - ).rejects.toThrow(); + ).rejects.toMatchObject({ code: 'ENOTDIR' }); expect(mockDispatch).toHaveBeenCalled(); expect(store.get('cwd:held-open:default')).toBeUndefined(); expect(store.getRuntimeHints('cwd:held-open:default')).toBeUndefined(); From 5fd249baecf4b2c52785ab5c95b576c406edad25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 12:21:32 +0200 Subject: [PATCH 43/44] refactor: make core fixtures declare session lifetimes --- .../app-log-session-resource.test.ts | 2 +- src/daemon/__tests__/back-runtime.test.ts | 2 +- .../__tests__/capture-disclosure.test.ts | 12 ++--- src/daemon/__tests__/daemon-policy.test.ts | 2 +- .../__tests__/daemon-runtime-app-log.test.ts | 2 +- src/daemon/__tests__/focus-runtime.test.ts | 2 +- src/daemon/__tests__/generic-settle.test.ts | 2 +- .../__tests__/human-control-router-fixture.ts | 2 +- src/daemon/__tests__/is-runtime.test.ts | 29 +++++----- src/daemon/__tests__/lease-lifecycle.test.ts | 4 +- .../open-device-contention-wait.test.ts | 13 +++-- .../__tests__/orientation-runtime.test.ts | 2 +- .../perf-capture-session-resource.test.ts | 2 +- ...eplay-divergence-android-occlusion.test.ts | 2 +- ...n-replay-divergence-capture-policy.test.ts | 4 +- ...on-replay-divergence-chrome-filter.test.ts | 17 ++++-- ...sion-replay-divergence-observation.test.ts | 5 +- .../session-replay-divergence-overlay.test.ts | 10 ++-- ...sion-replay-divergence-publication.test.ts | 2 +- ...sion-replay-divergence-suggestions.test.ts | 5 +- ...on-replay-maestro-error-projection.test.ts | 2 +- .../session-replay-maestro-failure.test.ts | 8 +-- ...n-replay-maestro-remote-evalscript.test.ts | 2 +- ...ion-replay-maestro-session-address.test.ts | 2 +- ...on-replay-runtime-maestro-dispatch.test.ts | 4 +- ...ay-runtime-maestro-shell-artifacts.test.ts | 2 +- .../session-replay-repair-acceptance.test.ts | 7 ++- .../session-replay-repair-empty-tail.test.ts | 30 ++++++++--- .../session-replay-repair-loop.test.ts | 13 +++-- ...ion-replay-repair-record-exclusion.test.ts | 5 +- ...-repair-transaction-close-ordering.test.ts | 4 ++ ...on-replay-repair-transaction-force.test.ts | 3 ++ ...sion-replay-repair-transaction.fixtures.ts | 7 ++- .../session-replay-repair-transaction.test.ts | 5 +- .../session-replay-repair.fixtures.ts | 19 ++++--- .../replay-runtime/application.test.ts | 6 +-- ...ion-replay-runtime-artifact-ledger.test.ts | 10 +++- .../session-replay-runtime-binding.test.ts | 2 +- ...on-replay-runtime-failure-response.test.ts | 22 ++++++-- .../session-replay-runtime-failure.test.ts | 33 ++++++++---- ...ession-replay-runtime-keep-session.test.ts | 18 +++---- .../session-replay-runtime-plan.test.ts | 28 +++++----- .../session-replay-runtime.fixtures.ts | 4 +- .../session-replay-runtime.test.ts | 45 ++++++++-------- .../session-replay-script-source.test.ts | 10 ++-- .../replay-suite/session-test-runner.test.ts | 8 +-- .../session-test-suite-command-video.test.ts | 4 +- .../replay-suite/session-test-suite.test.ts | 6 +-- ...sion-replay-dispatch-selector-miss.test.ts | 5 +- .../session-replay-scenario.fixtures.ts | 2 +- .../session-replay-selector-routes.test.ts | 2 +- .../request-dispatch-disclosure.test.ts | 2 +- ...quest-execution-scope-lease-expiry.test.ts | 2 +- ...t-execution-scope-open-device-wait.test.ts | 17 +++--- .../__tests__/request-execution-scope.test.ts | 38 ++++++------- ...quest-handler-chain-provider-scope.test.ts | 2 +- .../__tests__/request-handler-chain.test.ts | 4 +- .../request-router-android-modal.test.ts | 6 +-- .../request-router-android-perf.test.ts | 2 +- ...est-router-android-snapshot-helper.test.ts | 2 +- .../request-router-artifacts-web.test.ts | 2 +- .../__tests__/request-router-cost.test.ts | 10 ++-- .../__tests__/request-router-events.test.ts | 4 +- .../request-router-lock-policy.test.ts | 10 ++-- .../__tests__/request-router-open.test.ts | 6 +-- ...request-router-record-runtime-lock.test.ts | 2 +- .../request-router-recording-health.test.ts | 4 +- .../request-router-replay-scope.test.ts | 6 +-- .../request-router-response-level.test.ts | 2 +- .../request-router-screenshot.test.ts | 6 +-- .../request-router-typed-error.test.ts | 10 ++-- .../request-runtime-binding-router.test.ts | 2 +- .../request-save-script-transports.test.ts | 2 +- .../__tests__/runtime-binding-conformance.ts | 2 +- .../selector-capture-binding.test.ts | 4 +- .../selector-capture-runtime.test.ts | 6 +-- .../__tests__/selector-recording.test.ts | 10 ++-- .../__tests__/session-idle-activity.test.ts | 2 +- .../__tests__/session-idle-expiry-harness.ts | 4 +- src/daemon/__tests__/session-routing.test.ts | 54 ++++++++++--------- src/daemon/__tests__/session-store.test.ts | 6 +-- .../snapshot-command-runtime.test.ts | 2 +- ...shot-custom-actions-platform-guard.test.ts | 2 +- .../__tests__/snapshot-diff-runtime.test.ts | 6 +-- .../__tests__/snapshot-quality-latch.test.ts | 2 +- .../snapshot-runtime-binding.test.ts | 2 +- .../snapshot-runtime-disclosure.test.ts | 2 +- .../sparse-fallback-screenshot.test.ts | 5 +- .../__tests__/system-button-runtime.test.ts | 2 +- .../__tests__/tv-remote-runtime.test.ts | 2 +- src/daemon/__tests__/viewport-runtime.test.ts | 2 +- src/daemon/__tests__/wait-runtime.test.ts | 2 +- src/daemon/selector-runtime-backend.test.ts | 4 +- src/daemon/server/daemon-idle-reap.test.ts | 24 +++++---- .../daemon-runtime-device-claims.test.ts | 2 +- .../daemon-runtime-recording-teardown.test.ts | 4 +- .../server/daemon-runtime-web-cleanup.test.ts | 2 +- .../daemon-runtime-web-close-teardown.test.ts | 6 +-- ...mon-session-idle-expiry-scheduling.test.ts | 16 +++--- .../server/daemon-session-idle-expiry.test.ts | 12 +++-- 100 files changed, 435 insertions(+), 332 deletions(-) diff --git a/src/daemon/__tests__/app-log-session-resource.test.ts b/src/daemon/__tests__/app-log-session-resource.test.ts index bf65e67c88..de465b68e9 100644 --- a/src/daemon/__tests__/app-log-session-resource.test.ts +++ b/src/daemon/__tests__/app-log-session-resource.test.ts @@ -486,7 +486,7 @@ function makeContext( createdAt: Date.now(), actions: [], }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const resourcePath = appLogResourceStore.resolvePath(sessionStore.resolveSessionDir(sessionName)); return { admissionLedger: createAppLogAdmissionLedger(), diff --git a/src/daemon/__tests__/back-runtime.test.ts b/src/daemon/__tests__/back-runtime.test.ts index e2946824c3..55dd261633 100644 --- a/src/daemon/__tests__/back-runtime.test.ts +++ b/src/daemon/__tests__/back-runtime.test.ts @@ -138,7 +138,7 @@ test('request router joins back admission to execution, recording, and ref inval const sessionStore = makeSessionStore('agent-device-back-generic-'); const session = makeSession('back-runtime', { device: appleDevice }); activateCompleteRefFrame(session); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const handler = createRequestHandler({ logPath: '/tmp/daemon.log', token: 't', diff --git a/src/daemon/__tests__/capture-disclosure.test.ts b/src/daemon/__tests__/capture-disclosure.test.ts index e848ded858..59459f360f 100644 --- a/src/daemon/__tests__/capture-disclosure.test.ts +++ b/src/daemon/__tests__/capture-disclosure.test.ts @@ -75,7 +75,7 @@ beforeEach(() => { test('mutating find on a system-surface capture discloses the occlusion on the found outcome', async () => { const sessionStore = makeSessionStore(); const session = makeAndroidSession('default'); - sessionStore.set('default', session); + sessionStore.publish('default', session); const response = await handleFindCommands({ req: { @@ -102,7 +102,7 @@ test('mutating find on a system-surface capture discloses the occlusion on the f test('read-only find exists on a system-surface capture discloses the occlusion', async () => { const sessionStore = makeSessionStore(); const session = makeAndroidSession('default'); - sessionStore.set('default', session); + sessionStore.publish('default', session); const response = await dispatchFindReadOnlyViaRuntime({ req: { @@ -128,7 +128,7 @@ test('read-only find exists on a system-surface capture discloses the occlusion' test('wait timeout for app text hidden behind a system surface discloses the occlusion', async () => { const sessionStore = makeSessionStore(); const session = makeAndroidSession('default'); - sessionStore.set('default', session); + sessionStore.publish('default', session); const response = await dispatchWaitViaRuntime({ req: { @@ -304,7 +304,7 @@ function serveSheetCapture(): void { test('mutating find on an in-place system surface discloses it on the found outcome', async () => { serveSheetCapture(); const sessionStore = makeSessionStore(); - sessionStore.set('default', makeIosSession('default', { appBundleId: 'com.example.app' })); + sessionStore.publish('default', makeIosSession('default', { appBundleId: 'com.example.app' })); const response = await handleFindCommands({ req: { @@ -331,7 +331,7 @@ test('mutating find on an in-place system surface discloses it on the found outc test('mutating find that misses on an in-place system surface still discloses it', async () => { serveSheetCapture(); const sessionStore = makeSessionStore(); - sessionStore.set('default', makeIosSession('default', { appBundleId: 'com.example.app' })); + sessionStore.publish('default', makeIosSession('default', { appBundleId: 'com.example.app' })); const response = await handleFindCommands({ req: { @@ -378,7 +378,7 @@ test('the shared disclosure helper reports an iOS system surface on both outcome */ test('wait timeout whose polls required a foreground repair discloses the repair', async () => { const sessionStore = makeSessionStore(); - sessionStore.set('default', makeIosSession('default', { appBundleId: 'com.example.app' })); + sessionStore.publish('default', makeIosSession('default', { appBundleId: 'com.example.app' })); legacyDispatchCapture.mockResolvedValue({ backend: 'xctest', truncated: false, diff --git a/src/daemon/__tests__/daemon-policy.test.ts b/src/daemon/__tests__/daemon-policy.test.ts index 679f45fca9..489eaf5745 100644 --- a/src/daemon/__tests__/daemon-policy.test.ts +++ b/src/daemon/__tests__/daemon-policy.test.ts @@ -40,7 +40,7 @@ function makeHandler( options: { inventory?: readonly DeviceInfo[]; providerInventory?: readonly DeviceInfo[] } = {}, ) { const sessionStore = makeSessionStore('agent-device-daemon-policy-'); - sessionStore.set('default', makeIosSession('default', { appBundleId: 'com.example.app' })); + sessionStore.publish('default', makeIosSession('default', { appBundleId: 'com.example.app' })); const bind = vi.fn(lifecycleDeviceRuntimeGateway.bind); const inspectFacts = vi.fn(lifecycleDeviceRuntimeGateway.inspectFacts); const handler = createRequestHandler({ diff --git a/src/daemon/__tests__/daemon-runtime-app-log.test.ts b/src/daemon/__tests__/daemon-runtime-app-log.test.ts index 6fee6e32a4..08c46cb91b 100644 --- a/src/daemon/__tests__/daemon-runtime-app-log.test.ts +++ b/src/daemon/__tests__/daemon-runtime-app-log.test.ts @@ -123,7 +123,7 @@ test('daemon shutdown settles fenced app-log cleanup before finalization can rel forceCleanup, }); session.appLog = { handle, envelope }; - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const resourcePath = appLogResourceStore.resolvePath( sessionStore.resolveSessionDir(session.name), ); diff --git a/src/daemon/__tests__/focus-runtime.test.ts b/src/daemon/__tests__/focus-runtime.test.ts index 1d7f736966..7e4ea2f7fa 100644 --- a/src/daemon/__tests__/focus-runtime.test.ts +++ b/src/daemon/__tests__/focus-runtime.test.ts @@ -189,7 +189,7 @@ test('request router joins focus admission to execution, recording, and ref inva const sessionStore = makeSessionStore('agent-device-focus-generic-'); const session = makeSession('focus-runtime', { device: appleDevice }); activateCompleteRefFrame(session); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const handler = createRequestHandler({ logPath: '/tmp/daemon.log', token: 't', diff --git a/src/daemon/__tests__/generic-settle.test.ts b/src/daemon/__tests__/generic-settle.test.ts index afddd3d5f5..38469c4ac8 100644 --- a/src/daemon/__tests__/generic-settle.test.ts +++ b/src/daemon/__tests__/generic-settle.test.ts @@ -140,7 +140,7 @@ function seedSession( const session = makeIosSession(sessionName); setSessionSnapshot(session, buildSnapshotState(snapshotPayload(baseline), {})); activateCompleteRefFrame(session); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); return session; } diff --git a/src/daemon/__tests__/human-control-router-fixture.ts b/src/daemon/__tests__/human-control-router-fixture.ts index 8cbc7ee6d4..ddecdf3307 100644 --- a/src/daemon/__tests__/human-control-router-fixture.ts +++ b/src/daemon/__tests__/human-control-router-fixture.ts @@ -13,7 +13,7 @@ export function createHumanControlHarness() { const lease = registry.allocateLease(HUMAN_CONTROL_LEASE_REQUEST); const sessionStore = makeSessionStore('agent-device-human-control-'); const sessionName = tenantScopedSessionName(lease.tenantId, 'takeover-test'); - sessionStore.set( + sessionStore.publish( sessionName, makeIosAppSession(sessionName, { lease: buildSessionLeaseFromRequest(humanControlRequest(lease), lease), diff --git a/src/daemon/__tests__/is-runtime.test.ts b/src/daemon/__tests__/is-runtime.test.ts index 32c84ba5b3..68fb57ee65 100644 --- a/src/daemon/__tests__/is-runtime.test.ts +++ b/src/daemon/__tests__/is-runtime.test.ts @@ -74,7 +74,10 @@ function isRequest( test('an admitted is inspects once, binds once, and answers through the bound capture', async () => { const fixture = selectorCaptureFixture({ snapshot: () => buttonSnapshot() }); const sessionStore = makeSessionStore(); - sessionStore.set('is-bound', makeAndroidSession('is-bound', { appBundleId: 'com.example.app' })); + sessionStore.publish( + 'is-bound', + makeAndroidSession('is-bound', { appBundleId: 'com.example.app' }), + ); const response = await dispatchIsViaRuntime({ req: isRequest('is-bound', ['visible', 'id=auth_continue']), @@ -95,7 +98,7 @@ test('is absent uses the bound readAny capture for selector-first input without snapshot: () => ({ nodes: [], backend: 'xctest', producer: 'apple-runner' }), }); const sessionStore = makeSessionStore(); - sessionStore.set('is-absent', makeIosAppSession('is-absent')); + sessionStore.publish('is-absent', makeIosAppSession('is-absent')); const response = await dispatchIsViaRuntime({ req: isRequest('is-absent', ['label="Removed row"', 'absent']), @@ -133,7 +136,7 @@ test('is absent bypasses a cached no-match snapshot before evaluating the bound }), }); const sessionStore = makeSessionStore(); - sessionStore.set( + sessionStore.publish( 'is-absent-fresh', makeIosAppSession('is-absent-fresh', { snapshot: cachedAbsent }), ); @@ -168,7 +171,7 @@ test('is absent fails closed for a quality-less legacy iOS root-only capture', a }), }); const sessionStore = makeSessionStore(); - sessionStore.set('is-legacy-sparse', makeIosAppSession('is-legacy-sparse')); + sessionStore.publish('is-legacy-sparse', makeIosAppSession('is-legacy-sparse')); const response = await dispatchIsViaRuntime({ req: isRequest('is-legacy-sparse', ['absent', 'label="Removed row"']), @@ -200,7 +203,7 @@ test('is absent fails closed for a quality-less legacy iOS root-only capture', a test('is absent rejects depth and scope before binding with typed invalid arguments', async () => { const fixture = selectorCaptureFixture(); const sessionStore = makeSessionStore(); - sessionStore.set('is-absent-flags', makeIosAppSession('is-absent-flags')); + sessionStore.publish('is-absent-flags', makeIosAppSession('is-absent-flags')); for (const [flag, value] of [ ['snapshotScope', 'Login'], @@ -233,7 +236,7 @@ test('an unavailable capture fact refuses before any bind', async () => { // The watchOS sentinel shape: capability-supported today, no snapshot backend at the owner. const fixture = selectorCaptureFixture({ capture: unavailableCapture }); const sessionStore = makeSessionStore(); - sessionStore.set('is-refused', makeAndroidSession('is-refused', { appBundleId: 'com.a' })); + sessionStore.publish('is-refused', makeAndroidSession('is-refused', { appBundleId: 'com.a' })); const response = await dispatchIsViaRuntime({ req: isRequest('is-refused', ['visible', 'id=auth_continue']), @@ -262,7 +265,7 @@ test('an iOS session with no tracked app is refused with the open hint, not answ snapshot: () => buttonSnapshot(), }); const sessionStore = makeSessionStore(); - sessionStore.set('is-no-app', makeIosSession('is-no-app')); + sessionStore.publish('is-no-app', makeIosSession('is-no-app')); const response = await withTestDeviceInventory( {}, @@ -291,7 +294,7 @@ test('an iOS session WITH a tracked app still answers, so the refusal is the pla snapshot: () => buttonSnapshot(), }); const sessionStore = makeSessionStore(); - sessionStore.set('is-with-app', makeIosAppSession('is-with-app')); + sessionStore.publish('is-with-app', makeIosAppSession('is-with-app')); const response = await dispatchIsViaRuntime({ req: isRequest('is-with-app', ['visible', 'label=Continue']), @@ -308,7 +311,7 @@ test('an iOS session WITH a tracked app still answers, so the refusal is the pla test('an Android session with no tracked app proceeds, because the owner advertises the without-active-app capture', async () => { const fixture = selectorCaptureFixture({ snapshot: () => buttonSnapshot() }); const sessionStore = makeSessionStore(); - sessionStore.set('is-android-no-app', makeAndroidSession('is-android-no-app')); + sessionStore.publish('is-android-no-app', makeAndroidSession('is-android-no-app')); const response = await dispatchIsViaRuntime({ req: isRequest('is-android-no-app', ['visible', 'id=auth_continue']), @@ -329,7 +332,7 @@ test('an Android session with no tracked app proceeds, because the owner adverti test('a refused request reaches the device by no route at all', async () => { const fixture = selectorCaptureFixture({ capture: unavailableCapture }); const sessionStore = makeSessionStore(); - sessionStore.set('is-direct-refused', makeIosAppSession('is-direct-refused')); + sessionStore.publish('is-direct-refused', makeIosAppSession('is-direct-refused')); mockRunAppleRunnerCommand.mockResolvedValue({ found: true, nodes: [ @@ -389,7 +392,7 @@ test('a failing predicate answers COMMAND_FAILED from the bound capture', async }), }); const sessionStore = makeSessionStore(); - sessionStore.set('is-direct-false', makeIosAppSession('is-direct-false')); + sessionStore.publish('is-direct-false', makeIosAppSession('is-direct-false')); mockRunAppleRunnerCommand.mockResolvedValue({ found: true, text: 'Apple Account', @@ -444,7 +447,7 @@ test('a miss on a surface that never settled carries the unsettled fact, and the const sessionStore = makeSessionStore(); const session = makeIosAppSession('is-unsettled'); markDeferredInteractionOutcome({ session, command: 'scroll', positionals: [], flags: {} }); - sessionStore.set('is-unsettled', session); + sessionStore.publish('is-unsettled', session); const isVisible = () => dispatchIsViaRuntime({ req: isRequest('is-unsettled', ['visible', 'id=target']), @@ -486,7 +489,7 @@ test('a read after a scroll that moved nothing carries the no-effect outcome, an const sessionStore = makeSessionStore(); const session = makeIosAppSession('is-no-effect', { snapshot: makeSnapshotState([row]) }); markDeferredInteractionOutcome({ session, command: 'scroll', positionals: ['down'], flags: {} }); - sessionStore.set('is-no-effect', session); + sessionStore.publish('is-no-effect', session); const isVisible = () => dispatchIsViaRuntime({ req: isRequest('is-no-effect', ['visible', 'id=row']), diff --git a/src/daemon/__tests__/lease-lifecycle.test.ts b/src/daemon/__tests__/lease-lifecycle.test.ts index 0ef074372c..365db14d47 100644 --- a/src/daemon/__tests__/lease-lifecycle.test.ts +++ b/src/daemon/__tests__/lease-lifecycle.test.ts @@ -22,7 +22,7 @@ test('admitRequestLeaseForLockedScope heartbeats and stores admitted lease on th deviceKey: 'ios:SIM-001', clientId: 'client-a', }); - sessionStore.set( + sessionStore.publish( 'default', makeIosSession('default', { lease: { @@ -71,7 +71,7 @@ test('cleanupExpiredLeasedSession consumes expired lease and deletes the session expiresAt: lease.expiresAt, }, }); - sessionStore.set('default', session); + sessionStore.publish('default', session); now = 1_011; const teardownSession = vi.fn(async () => {}); diff --git a/src/daemon/__tests__/open-device-contention-wait.test.ts b/src/daemon/__tests__/open-device-contention-wait.test.ts index 5b3749c60d..9c0bc8ac5d 100644 --- a/src/daemon/__tests__/open-device-contention-wait.test.ts +++ b/src/daemon/__tests__/open-device-contention-wait.test.ts @@ -56,7 +56,7 @@ function session(address: string): SessionState { function storeWithHolder(): SessionStore { const store = makeSessionStore('agent-device-open-wait-'); - store.set(HOLDER_ADDRESS, session(HOLDER_ADDRESS)); + store.publish(HOLDER_ADDRESS, session(HOLDER_ADDRESS)); return store; } @@ -161,7 +161,7 @@ test('only a fresh open with a budget and a resolved device gets a wait', () => ).toBeUndefined(); // An open onto a session that already exists is bound to a device nobody else is refused for. - sessionStore.set(OPENER_ADDRESS, session(OPENER_ADDRESS)); + sessionStore.publish(OPENER_ADDRESS, session(OPENER_ADDRESS)); expect( beginWait({ req: budget, @@ -193,7 +193,8 @@ test('a device that frees up ends the wait without claiming a spent budget', asy vi.useFakeTimers(); const req = openRequest({ waitMs: 30_000 }); const store = storeWithHolder(); - const release = setTimeout(() => store.delete(HOLDER_ADDRESS), 600); + const holder = store.lookup(HOLDER_ADDRESS)!; + const release = setTimeout(() => store.retire(holder), 600); await waitUntil( () => @@ -223,10 +224,12 @@ test('an open yields the device lock to a session that took the device after the })!; // A competing open puts its session on the device in the window between this open's look at the // free store and its first pass under the locks, and hands it back 300ms later. - setTimeout(() => store.delete(HOLDER_ADDRESS), 300); const trace = lockTrace({ onAcquire: (pass) => { - if (pass === 1) store.set(HOLDER_ADDRESS, session(HOLDER_ADDRESS)); + if (pass === 1) { + const holder = store.publish(HOLDER_ADDRESS, session(HOLDER_ADDRESS)); + setTimeout(() => store.retire(holder), 300); + } }, }); diff --git a/src/daemon/__tests__/orientation-runtime.test.ts b/src/daemon/__tests__/orientation-runtime.test.ts index 65be8bce62..218fca919f 100644 --- a/src/daemon/__tests__/orientation-runtime.test.ts +++ b/src/daemon/__tests__/orientation-runtime.test.ts @@ -195,7 +195,7 @@ test('request router joins orientation admission to execution and ref invalidati const sessionStore = makeSessionStore('agent-device-orientation-generic-'); const session = makeSession('orientation-runtime', { device: testDevice }); activateCompleteRefFrame(session); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const handler = createRequestHandler({ logPath: '/tmp/daemon.log', token: 't', diff --git a/src/daemon/__tests__/perf-capture-session-resource.test.ts b/src/daemon/__tests__/perf-capture-session-resource.test.ts index 7441e03774..de425c50ac 100644 --- a/src/daemon/__tests__/perf-capture-session-resource.test.ts +++ b/src/daemon/__tests__/perf-capture-session-resource.test.ts @@ -52,7 +52,7 @@ test('a perf stop whose pull failed re-collects the device-side trace the first createdAt: 1, actions: [], }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const capture = { action: 'start' as const, kind: 'perfetto' as const, diff --git a/src/daemon/__tests__/replay-divergence/session-replay-divergence-android-occlusion.test.ts b/src/daemon/__tests__/replay-divergence/session-replay-divergence-android-occlusion.test.ts index 6f03e1c8ff..0b35a54061 100644 --- a/src/daemon/__tests__/replay-divergence/session-replay-divergence-android-occlusion.test.ts +++ b/src/daemon/__tests__/replay-divergence/session-replay-divergence-android-occlusion.test.ts @@ -52,7 +52,7 @@ test.each([ const root = mkdtempForTestSync('agent-device-replay-divergence-qsshade-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set( + sessionStore.publish( sessionName, makeAndroidSession(sessionName, { appBundleId: 'com.google.android.deskclock' }), ); diff --git a/src/daemon/__tests__/replay-divergence/session-replay-divergence-capture-policy.test.ts b/src/daemon/__tests__/replay-divergence/session-replay-divergence-capture-policy.test.ts index 8bb184e656..fd0ed718de 100644 --- a/src/daemon/__tests__/replay-divergence/session-replay-divergence-capture-policy.test.ts +++ b/src/daemon/__tests__/replay-divergence/session-replay-divergence-capture-policy.test.ts @@ -61,7 +61,7 @@ test('buildReplayFailureDivergence: routes through the freshness-retry wrapper a baselineCount: 20, routeComparable: false, }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); // Capture 1: stale, near-empty dump (a single bare view — no hittable/label/ // id) → `sharp-drop` vs the 20-node baseline → triggers a retry. @@ -149,7 +149,7 @@ test('buildReplayFailureDivergence: divergence capture drops the action snapshot const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; const appBundleId = 'com.callstack.agentdevicelab'; - sessionStore.set(sessionName, makeAndroidSession(sessionName, { appBundleId })); + sessionStore.publish(sessionName, makeAndroidSession(sessionName, { appBundleId })); mockDispatchCommand.mockReset(); mockDispatchCommand.mockResolvedValue({ diff --git a/src/daemon/__tests__/replay-divergence/session-replay-divergence-chrome-filter.test.ts b/src/daemon/__tests__/replay-divergence/session-replay-divergence-chrome-filter.test.ts index 96b8ce8153..df49dfb223 100644 --- a/src/daemon/__tests__/replay-divergence/session-replay-divergence-chrome-filter.test.ts +++ b/src/daemon/__tests__/replay-divergence/session-replay-divergence-chrome-filter.test.ts @@ -101,7 +101,10 @@ test('buildReplayFailureDivergence excludes keyboard chrome from screen.refs and const root = mkdtempForTestSync('agent-device-replay-divergence-keyboard-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); mockDispatchCommand.mockResolvedValue({ nodes: keyboardSwampedNodes('Push Article'), @@ -209,7 +212,10 @@ test('buildReplayFailureDivergence drops unlabeled non-interactive structural no const root = mkdtempForTestSync('agent-device-replay-divergence-structural-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); mockDispatchCommand.mockResolvedValue({ nodes: structuralNoiseNodes(), @@ -321,7 +327,10 @@ test('buildReplayFailureDivergence keeps an app inputAccessoryView control in sc const root = mkdtempForTestSync('agent-device-replay-divergence-accessory-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); mockDispatchCommand.mockResolvedValue({ nodes: keyboardWithAccessoryNodes(), @@ -373,7 +382,7 @@ test('buildReplayFailureDivergence excludes Android status-bar/IME chrome from s const root = mkdtempForTestSync('agent-device-replay-divergence-android-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set( + sessionStore.publish( sessionName, makeAndroidSession(sessionName, { appBundleId: 'com.callstack.agentdevicelab' }), ); diff --git a/src/daemon/__tests__/replay-divergence/session-replay-divergence-observation.test.ts b/src/daemon/__tests__/replay-divergence/session-replay-divergence-observation.test.ts index dc7e7edaac..ada39067fe 100644 --- a/src/daemon/__tests__/replay-divergence/session-replay-divergence-observation.test.ts +++ b/src/daemon/__tests__/replay-divergence/session-replay-divergence-observation.test.ts @@ -50,7 +50,10 @@ test('captureDivergenceObservation retryLaunchRace: the 12s deadline bounds retr const root = mkdtempForTestSync('agent-device-replay-divergence-deadline-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); mockDispatchCommand.mockImplementation(async () => { vi.setSystemTime(new Date(Date.now() + 5_000)); diff --git a/src/daemon/__tests__/replay-divergence/session-replay-divergence-overlay.test.ts b/src/daemon/__tests__/replay-divergence/session-replay-divergence-overlay.test.ts index b72ebe7b4d..29d55a2f24 100644 --- a/src/daemon/__tests__/replay-divergence/session-replay-divergence-overlay.test.ts +++ b/src/daemon/__tests__/replay-divergence/session-replay-divergence-overlay.test.ts @@ -49,7 +49,7 @@ test('buildReplayFailureDivergence: a system-overlay window survives into screen const root = mkdtempForTestSync('agent-device-replay-divergence-overlay-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set( + sessionStore.publish( sessionName, makeAndroidSession(sessionName, { appBundleId: 'com.callstack.agentdevicelab' }), ); @@ -163,7 +163,7 @@ test('buildReplayFailureDivergence: a fully-captured overlay dismiss-target enum const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; const appBundleId = 'com.callstack.agentdevicelab'; - sessionStore.set(sessionName, makeAndroidSession(sessionName, { appBundleId })); + sessionStore.publish(sessionName, makeAndroidSession(sessionName, { appBundleId })); mockDispatchCommand.mockResolvedValue({ nodes: appSwampWithTrailingOverlay(appBundleId), @@ -259,7 +259,7 @@ test('buildReplayFailureDivergence: when a system overlay mass-covers the app, t const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; const appBundleId = 'com.callstack.agentdevicelab'; - sessionStore.set(sessionName, makeAndroidSession(sessionName, { appBundleId })); + sessionStore.publish(sessionName, makeAndroidSession(sessionName, { appBundleId })); mockDispatchCommand.mockResolvedValue({ nodes: overlayMassCoveringApp(appBundleId), @@ -334,7 +334,7 @@ test('buildReplayFailureDivergence: a mass-covered app with no actionable overla const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; const appBundleId = 'com.callstack.agentdevicelab'; - sessionStore.set(sessionName, makeAndroidSession(sessionName, { appBundleId })); + sessionStore.publish(sessionName, makeAndroidSession(sessionName, { appBundleId })); mockDispatchCommand.mockResolvedValue({ nodes: bareScrimMassCoveringApp(appBundleId), @@ -384,7 +384,7 @@ test('buildReplayFailureDivergence: the partial ref frame authorizes exactly the const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; const appBundleId = 'com.callstack.agentdevicelab'; - sessionStore.set(sessionName, makeAndroidSession(sessionName, { appBundleId })); + sessionStore.publish(sessionName, makeAndroidSession(sessionName, { appBundleId })); mockDispatchCommand.mockReset(); mockDispatchCommand.mockResolvedValue({ diff --git a/src/daemon/__tests__/replay-divergence/session-replay-divergence-publication.test.ts b/src/daemon/__tests__/replay-divergence/session-replay-divergence-publication.test.ts index 916e7b5238..e11375756c 100644 --- a/src/daemon/__tests__/replay-divergence/session-replay-divergence-publication.test.ts +++ b/src/daemon/__tests__/replay-divergence/session-replay-divergence-publication.test.ts @@ -43,7 +43,7 @@ function scenario(refCount = 20) { }; setSessionSnapshot(session, prior); markSessionPartialRefsIssued(session, ['old']); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const replaySession = replaySessionForTest(sessionStore, sessionName); const snapshot: SnapshotState = { createdAt: 2, diff --git a/src/daemon/__tests__/replay-divergence/session-replay-divergence-suggestions.test.ts b/src/daemon/__tests__/replay-divergence/session-replay-divergence-suggestions.test.ts index eeef2819df..bea2d6185f 100644 --- a/src/daemon/__tests__/replay-divergence/session-replay-divergence-suggestions.test.ts +++ b/src/daemon/__tests__/replay-divergence/session-replay-divergence-suggestions.test.ts @@ -27,7 +27,10 @@ test('buildReplayFailureDivergence dedupes suggestions using the strongest basis const root = mkdtempForTestSync('agent-device-replay-suggest-dedupe-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); mockDispatchCommand.mockResolvedValue({ nodes: [ diff --git a/src/daemon/__tests__/replay-maestro/session-replay-maestro-error-projection.test.ts b/src/daemon/__tests__/replay-maestro/session-replay-maestro-error-projection.test.ts index a9593097bd..4fecc2aa32 100644 --- a/src/daemon/__tests__/replay-maestro/session-replay-maestro-error-projection.test.ts +++ b/src/daemon/__tests__/replay-maestro/session-replay-maestro-error-projection.test.ts @@ -20,7 +20,7 @@ test('a typed Maestro replay error keeps its recovery hint', async () => { const flowPath = path.join(root, 'flow.yaml'); fs.writeFileSync(flowPath, `${stringify({ appId: 'com.example.app' })}---\n${stringify([])}`); const sessionStore = new SessionStore(path.join(root, 'sessions')); - sessionStore.set('default', makeIosSession('default')); + sessionStore.publish('default', makeIosSession('default')); const req = { token: 'test-token', diff --git a/src/daemon/__tests__/replay-maestro/session-replay-maestro-failure.test.ts b/src/daemon/__tests__/replay-maestro/session-replay-maestro-failure.test.ts index 13b01bec0e..d3bc88955b 100644 --- a/src/daemon/__tests__/replay-maestro/session-replay-maestro-failure.test.ts +++ b/src/daemon/__tests__/replay-maestro/session-replay-maestro-failure.test.ts @@ -59,7 +59,7 @@ async function buildFailureScenario( const root = mkdtempForTestSync('agent-device-maestro-suggestions-'); const sessionName = 'default'; const sessionStore = new SessionStore(path.join(root, 'sessions')); - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const replaySession = replaySessionForTest(sessionStore, sessionName); mockDispatchCommand.mockResolvedValue({ nodes, truncated: false, backend: 'xctest' }); const failure = await captureMaestroFailure(command, path.join(root, 'flow.yaml')); @@ -177,7 +177,7 @@ test('typed Maestro failure diagnostics render expanded selector values without const root = mkdtempForTestSync('agent-device-maestro-expanded-selector-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const flowPath = path.join(root, 'flow.yaml'); const label = 'Continue checkout'; fs.writeFileSync( @@ -240,7 +240,7 @@ test('typed Maestro nested scopes retain resolved target values after unwind', a const root = mkdtempForTestSync('agent-device-maestro-nested-redaction-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const flowPath = path.join(root, 'flow.yaml'); const tracePath = path.join(root, 'replay-timing.ndjson'); const targetLabel = 'Nested checkout target'; @@ -336,7 +336,7 @@ test('typed Maestro renders flow-local values when static include resolution fai const root = mkdtempForTestSync('agent-device-maestro-include-redaction-'); const sessionName = 'default'; const sessionStore = new SessionStore(path.join(root, 'sessions')); - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const flowPath = path.join(root, 'flow.yaml'); const flowName = 'checkout-details'; fs.writeFileSync( diff --git a/src/daemon/__tests__/replay-maestro/session-replay-maestro-remote-evalscript.test.ts b/src/daemon/__tests__/replay-maestro/session-replay-maestro-remote-evalscript.test.ts index 933c71086e..2ad7742cdd 100644 --- a/src/daemon/__tests__/replay-maestro/session-replay-maestro-remote-evalscript.test.ts +++ b/src/daemon/__tests__/replay-maestro/session-replay-maestro-remote-evalscript.test.ts @@ -47,7 +47,7 @@ async function runWithNetworkFlag(publicNetworkOnly: boolean | undefined) { const root = mkdtempForTestSync('agent-device-maestro-remote-wire-'); const flowPath = writeFlow(root, 'flow.yaml'); const sessionStore = new SessionStore(path.join(root, 'sessions')); - sessionStore.set('default', makeIosSession('default')); + sessionStore.publish('default', makeIosSession('default')); const req = { token: 'test-token', diff --git a/src/daemon/__tests__/replay-maestro/session-replay-maestro-session-address.test.ts b/src/daemon/__tests__/replay-maestro/session-replay-maestro-session-address.test.ts index 0e0a7e8154..aa47b06842 100644 --- a/src/daemon/__tests__/replay-maestro/session-replay-maestro-session-address.test.ts +++ b/src/daemon/__tests__/replay-maestro/session-replay-maestro-session-address.test.ts @@ -22,7 +22,7 @@ test('a typed Maestro selector conflict names the session store key, not "defaul const sessionStore = new SessionStore(path.join(root, 'sessions')); const session = makeIosSession('default'); session.sessionScope = { kind: 'cwd', id: '8bea844ab16aa9b3' }; - sessionStore.set(SCOPED_ADDRESS, session); + sessionStore.publish(SCOPED_ADDRESS, session); const req = { token: 'test-token', diff --git a/src/daemon/__tests__/replay-maestro/session-replay-runtime-maestro-dispatch.test.ts b/src/daemon/__tests__/replay-maestro/session-replay-runtime-maestro-dispatch.test.ts index 6385f6edbf..36fd939d73 100644 --- a/src/daemon/__tests__/replay-maestro/session-replay-runtime-maestro-dispatch.test.ts +++ b/src/daemon/__tests__/replay-maestro/session-replay-runtime-maestro-dispatch.test.ts @@ -116,7 +116,7 @@ test('runReplayCommand reports snapshot diagnostics from per-action session samp ['snapshot', 'snapshot', 'snapshot', 'snapshot', 'snapshot', ''].join('\n'), ); const sessionStore = new SessionStore(path.join(root, 'state')); - sessionStore.set( + sessionStore.publish( 's', makeIosSession('s', { snapshotDiagnostics: { samples: [] }, @@ -180,7 +180,7 @@ test('runReplayCommand reports snapshot diagnostics on replay failure', async () ['snapshot', 'snapshot', 'snapshot', 'snapshot', 'click "Missing"', ''].join('\n'), ); const sessionStore = new SessionStore(path.join(root, 'state')); - sessionStore.set( + sessionStore.publish( 's', makeIosSession('s', { snapshotDiagnostics: { samples: [] }, diff --git a/src/daemon/__tests__/replay-maestro/session-replay-runtime-maestro-shell-artifacts.test.ts b/src/daemon/__tests__/replay-maestro/session-replay-runtime-maestro-shell-artifacts.test.ts index fafe08603d..0c58468436 100644 --- a/src/daemon/__tests__/replay-maestro/session-replay-runtime-maestro-shell-artifacts.test.ts +++ b/src/daemon/__tests__/replay-maestro/session-replay-runtime-maestro-shell-artifacts.test.ts @@ -46,7 +46,7 @@ test('runReplayCommand writes per-action timing events to active trace', async ( fs.writeFileSync(tracePath, ''); const sessionStore = new SessionStore(path.join(root, 'state')); - sessionStore.set('s', { + sessionStore.publish('s', { name: 's', device: { platform: 'apple', diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair-acceptance.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair-acceptance.test.ts index 617aabcd7e..2461acd073 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-acceptance.test.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-acceptance.test.ts @@ -77,7 +77,10 @@ test('a healed script survives repair + fresh-session replay: self-contained ope const root = mkdtempForTestSync('agent-device-replay-repair-accept-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); const filePath = writeReplayFile(root, [ 'open "Demo" --relaunch --platform ios --metro-port 8081', SAVE_ANNOTATION, @@ -163,7 +166,7 @@ test('a healed script survives repair + fresh-session replay: self-contained ope invoke: makeRecordingReplayInvoke({ sessionStore: freshSessionStore, sessionName: freshSessionName, - openReplacesSession: true, + openRebuildsActions: true, spy: invokedFresh, }), }); diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair-empty-tail.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair-empty-tail.test.ts index f6e7b0883d..33a6908f82 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-empty-tail.test.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-empty-tail.test.ts @@ -86,7 +86,10 @@ test('a record-and-heal divergence on the LAST step resumes with an empty tail a const root = mkdtempForTestSync('agent-device-replay-empty-tail-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); const evidence = await recordArticleEvidence(); const filePath = writeReplayFile(root, [ 'open "Demo" --relaunch', @@ -213,7 +216,10 @@ test('a manual divergence (unannotated action-failure) on the LAST step resumes const root = mkdtempForTestSync('agent-device-replay-empty-tail-manual-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); // No target-v1 annotation: an unannotated action-failure always routes to // the `manual` fail-safe (no recorded targetEvidence). const filePath = writeReplayFile(root, ['open "Demo" --relaunch', 'click label="Save"']); @@ -327,7 +333,10 @@ test('a caution (identity-mismatch) divergence on the LAST step resumes with an const root = mkdtempForTestSync('agent-device-replay-empty-tail-caution-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); const SAVE_ANNOTATION = '# agent-device:target-v1 {"id":"save","role":"button","label":"Save","ancestry":[],"sibling":0,"viewportOrder":0,"verification":"verified"}'; const filePath = writeReplayFile(root, [ @@ -451,7 +460,10 @@ test('--from N stays legal for a caution divergence even after the N + 1 empty-t const root = mkdtempForTestSync('agent-device-replay-empty-tail-caution-from-n-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); const SAVE_ANNOTATION = '# agent-device:target-v1 {"id":"save","role":"button","label":"Save","ancestry":[],"sibling":0,"viewportOrder":0,"verification":"verified"}'; const filePath = writeReplayFile(root, [ @@ -545,7 +557,10 @@ test('an unauthorized --from one past the plan end is rejected on an ARMED sessi const root = mkdtempForTestSync('agent-device-replay-empty-tail-state-repair-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); const evidence = await recordArticleEvidence(); const filePath = writeReplayFile(root, [ 'open "Demo" --relaunch', @@ -628,7 +643,10 @@ test('a stale --plan-digest on an empty-tail resume is rejected WITHOUT consumin const root = mkdtempForTestSync('agent-device-replay-empty-tail-digest-retry-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); const evidence = await recordArticleEvidence(); const filePath = writeReplayFile(root, [ 'open "Demo" --relaunch', diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair-loop.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair-loop.test.ts index 2a00c0e8ac..efef45cdff 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-loop.test.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-loop.test.ts @@ -84,7 +84,7 @@ function setup(prefix: string, sessionOverrides = {}) { const root = mkdtempForTestSync(prefix); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, sessionOverrides)); + sessionStore.publish(sessionName, makeIosSession(sessionName, sessionOverrides)); return { root, sessionStore, sessionName, logPath: path.join(root, 'daemon.log') }; } @@ -243,7 +243,7 @@ test('R2 bypass guard: a PLAIN full replay (no --save-script) on an armed sessio expect(sessionStore.get(sessionName)!.actions.length).toBe(armedActionCount); }); -test('R6 no amputation: a pre-populated session whose step-1 open REPLACES the session healed-slices exactly this run', async () => { +test('R6 no amputation: rebuilding the action list during open heals exactly this run', async () => { // Pre-seed with 2 prior, unrelated actions. const { root, sessionStore, sessionName, logPath } = setup( 'agent-device-replay-repair-amputate-', @@ -260,13 +260,12 @@ test('R6 no amputation: a pre-populated session whose step-1 open REPLACES the s 'click id="b"', ]); - // open REPLACES the session with a fresh `actions: []` one (the real - // new-session branch, session-open-surface.ts) — the case the old pre-loop - // boundary=N would amputate (slice(2) drops the healed open + first click). + // Resetting the action list would make the pre-loop boundary amputate the + // healed open and first click. The coordinator must use the rebuilt record. const invoke = makeRecordingReplayInvoke({ sessionStore, sessionName, - openReplacesSession: true, + openRebuildsActions: true, evidence: (req) => (req.command === 'click' ? freshEvidence('x', 'X') : undefined), }); @@ -528,7 +527,7 @@ test('Fix 3: only the TERMINAL close is skipped during a repair — a mid-plan c sessionStore, sessionName, spy, - openReplacesSession: true, + openRebuildsActions: true, }); const response = await runReplayForTest({ diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts index e998f0bcf0..81a7d9fbdb 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-record-exclusion.test.ts @@ -139,7 +139,10 @@ function setup(prefix: string) { const root = mkdtempForTestSync(prefix); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); return { root, sessionStore, 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 c4065cff2d..f618e7a798 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-close-ordering.test.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-close-ordering.test.ts @@ -94,6 +94,7 @@ test('BLOCKER 2 (new): a repair close whose PLATFORM close fails never commits a const { root, sessionStore, sessionName, logPath, leaseRegistry } = setup( 'agent-device-repair-transaction-platform-close-fail-', ); + sessionStore.retire(sessionStore.lookup(sessionName)!); const session = makeCompleteRepairSession(sessionStore, sessionName, root); const healedPath = path.join(root, 'flow.healed.ad'); @@ -175,6 +176,7 @@ test('BLOCKER 3 (second follow-up): a retry after a SUCCESSFUL platform close bu const { root, sessionStore, sessionName, logPath, leaseRegistry } = setup( 'agent-device-repair-transaction-close-idempotent-', ); + sessionStore.retire(sessionStore.lookup(sessionName)!); makeCompleteRepairSession(sessionStore, sessionName, root); const healedPath = path.join(root, 'flow.healed.ad'); // A prior COMPLETE (sentinel-marked) healed artifact already sits at the @@ -273,6 +275,7 @@ test('BLOCKER 3 (third follow-up): an untargeted close that performed NO platfor const { root, sessionStore, sessionName, logPath, leaseRegistry } = setup( 'agent-device-repair-transaction-close-identity-untargeted-', ); + sessionStore.retire(sessionStore.lookup(sessionName)!); makeCompleteRepairSession(sessionStore, sessionName, root); const healedPath = path.join(root, 'flow.healed.ad'); // A prior COMPLETE artifact already sits at the default path so the commit @@ -318,6 +321,7 @@ test('BLOCKER 3 (third follow-up): a retry targeting a DIFFERENT app than the su const { root, sessionStore, sessionName, logPath, leaseRegistry } = setup( 'agent-device-repair-transaction-close-identity-changed-target-', ); + sessionStore.retire(sessionStore.lookup(sessionName)!); makeCompleteRepairSession(sessionStore, sessionName, root); const healedPath = path.join(root, 'flow.healed.ad'); fs.writeFileSync( diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-force.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-force.test.ts index 4f2192f2ea..034783ee9f 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-force.test.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction-force.test.ts @@ -111,6 +111,7 @@ test('#1258: close --save-script --force overwrites an existing COMPLETE healed const { root, sessionStore, sessionName, logPath, leaseRegistry } = setup( 'agent-device-repair-transaction-force-overwrite-', ); + sessionStore.retire(sessionStore.lookup(sessionName)!); makeCompleteRepairSession(sessionStore, sessionName, root); // A prior COMPLETE (sentinel-marked) healed artifact already sits at the // default path — `--force` must overwrite it instead of refusing. @@ -392,6 +393,7 @@ test('#1258 force is per-target: re-arming --save-script= WITHOUT --force dro 'agent-device-repair-transaction-retarget-clears-force-', ); // Armed and forced for target (flow.healed.ad). + sessionStore.retire(sessionStore.lookup(sessionName)!); const session = makeCompleteRepairSession(sessionStore, sessionName, root); session.scriptPublication = repairPublication('complete', { boundary: 0, @@ -437,6 +439,7 @@ test('#1258 force per-target, contrast: re-arming --save-script= WITH --force const { root, sessionStore, sessionName, logPath, leaseRegistry } = setup( 'agent-device-repair-transaction-retarget-force-overwrites-', ); + sessionStore.retire(sessionStore.lookup(sessionName)!); const session = makeCompleteRepairSession(sessionStore, sessionName, root); session.scriptPublication = repairPublication('complete', { boundary: 0, diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction.fixtures.ts b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction.fixtures.ts index eecc71704a..66e45ec990 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction.fixtures.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction.fixtures.ts @@ -32,7 +32,10 @@ export function setup(prefix: string) { const root = mkdtempForTestSync(prefix); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); return { root, sessionStore, @@ -74,6 +77,6 @@ export function makeCompleteRepairSession( }, ], }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); return session; } diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction.test.ts b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction.test.ts index a8841a4ffe..8412b5609d 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair-transaction.test.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair-transaction.test.ts @@ -306,7 +306,7 @@ test('C5a: an incomplete repair reaped by idle-reap leaves a tombstone (no heale // nothing (not complete) and a tombstone is left behind (the exact teardown // step daemon-runtime.ts's teardownDaemonSession runs). sessionStore.finalizeRepairTeardown(storeSessionForTest(sessionStore, session)); - sessionStore.delete(sessionName); + sessionStore.retire(sessionStore.lookup(sessionName)!); expect(fs.existsSync(path.join(root, 'flow.healed.ad'))).toBe(false); const tombstone = sessionStore.readRepairTombstone(sessionName); @@ -400,7 +400,7 @@ test('BLOCKER 1: a --from continuation on a reaped session returns SESSION_NOT_F // Idle-reap tears the incomplete repair down, leaving a tombstone. sessionStore.finalizeRepairTeardown(sessionStore.lookup(sessionName)!); - sessionStore.delete(sessionName); + sessionStore.retire(sessionStore.lookup(sessionName)!); expect(sessionStore.readRepairTombstone(sessionName)).toBeDefined(); // A `--from` continuation targeting the (now reaped) session must surface @@ -423,6 +423,7 @@ test('BLOCKER 2b/2c: a close whose commit FAILS (no-clobber) keeps the session f const { root, sessionStore, sessionName, logPath, leaseRegistry } = setup( 'agent-device-repair-transaction-commit-fail-', ); + sessionStore.retire(sessionStore.lookup(sessionName)!); makeCompleteRepairSession(sessionStore, sessionName, root); // A prior COMPLETE (sentinel-marked) healed artifact already sits at the // default path — the commit must refuse to clobber it. diff --git a/src/daemon/__tests__/replay-repair/session-replay-repair.fixtures.ts b/src/daemon/__tests__/replay-repair/session-replay-repair.fixtures.ts index ab83d1ddfc..77f99b41b2 100644 --- a/src/daemon/__tests__/replay-repair/session-replay-repair.fixtures.ts +++ b/src/daemon/__tests__/replay-repair/session-replay-repair.fixtures.ts @@ -1,5 +1,6 @@ import { storeSessionForTest } from '../../../__tests__/test-utils/store-factory.ts'; import { isSessionRecording } from '../../session-script-publication-capability.ts'; +import { NO_SCRIPT_PUBLICATION } from '../../session-script-publication-state.ts'; /** * Shared fixtures for the ADR 0012 decision 6 repair-loop tests. The mock * `invoke` in these tests must ACTUALLY record via `sessionStore.recordAction` @@ -32,11 +33,10 @@ export type RecordingReplayInvokeConfig = { /** Records every request seen, in order — for asserting dispatch order/flags. */ spy?: DaemonRequest[]; /** - * When true, `open` REPLACES the session with a fresh `actions: []` one — - * mimicking `session-open-surface.ts`'s new-session branch. Default records - * onto the existing session (creating one only if none exists yet). + * Rebuild the same lifetime with an empty action list during open, exercising + * healed-slice boundaries when the underlying record changes. */ - openReplacesSession?: boolean; + openRebuildsActions?: boolean; /** * Steps that fail: return `{ ok: false }` WITHOUT recording — mimicking a * dispatch failure that never reaches `finalizeTouchInteraction`. Keyed by @@ -86,10 +86,13 @@ function isFailStep(failSteps: ReadonlySet | undefined, req: DaemonReque } function resolveInvokeSession(config: RecordingReplayInvokeConfig, req: DaemonRequest) { - const existing = config.sessionStore.get(config.sessionName); - const mustCreate = req.command === 'open' && (config.openReplacesSession || !existing); - if (!mustCreate && existing) return existing; + const ref = config.sessionStore.lookup(config.sessionName); + if (ref) { + return req.command === 'open' && config.openRebuildsActions + ? config.sessionStore.update(ref, { actions: [], scriptPublication: NO_SCRIPT_PUBLICATION }) + : config.sessionStore.requireCurrent(ref); + } const created = makeIosSession(config.sessionName); - config.sessionStore.set(config.sessionName, created); + config.sessionStore.publish(config.sessionName, created); return created; } diff --git a/src/daemon/__tests__/replay-runtime/application.test.ts b/src/daemon/__tests__/replay-runtime/application.test.ts index d72e748a3d..9dd0b598e6 100644 --- a/src/daemon/__tests__/replay-runtime/application.test.ts +++ b/src/daemon/__tests__/replay-runtime/application.test.ts @@ -102,7 +102,7 @@ test('replay routes native and Maestro sources through one application seam and for (const replayCase of cases) { const root = mkdtempForTestSync(`agent-device-replay-application-${replayCase.kind}-`); const sessionStore = new SessionStore(path.join(root, 'sessions')); - sessionStore.set('default', makeIosSession('default')); + sessionStore.publish('default', makeIosSession('default')); const req = await replayCase.buildRequest(root); const invoked: DaemonRequest[] = []; @@ -130,7 +130,7 @@ test('replay routes native and Maestro sources through one application seam and test('replay admits an annotated target before dispatching its mutation', async () => { const root = mkdtempForTestSync('agent-device-replay-application-admission-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); - sessionStore.set('default', makeIosSession('default', { appBundleId: 'com.example.app' })); + sessionStore.publish('default', makeIosSession('default', { appBundleId: 'com.example.app' })); const filePath = writeReplayFile(root, [ '# agent-device:target-v1 {"id":"save","role":"button","label":"Save","ancestry":[],"sibling":0,"viewportOrder":0,"verification":"verified"}', 'click label="Save"', @@ -168,7 +168,7 @@ test('replay admits an annotated target before dispatching its mutation', async test('replay projects the exact failure cause and retains artifacts from the failed step', async () => { const root = mkdtempForTestSync('agent-device-replay-application-failure-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); - sessionStore.set('default', makeIosSession('default')); + sessionStore.publish('default', makeIosSession('default')); const artifactPath = path.join(root, 'failure.png'); fs.writeFileSync(artifactPath, 'artifact'); const filePath = writeReplayFile(root, ['open "Demo"']); diff --git a/src/daemon/__tests__/replay-runtime/session-replay-runtime-artifact-ledger.test.ts b/src/daemon/__tests__/replay-runtime/session-replay-runtime-artifact-ledger.test.ts index 6417a461dd..c5f8e9045e 100644 --- a/src/daemon/__tests__/replay-runtime/session-replay-runtime-artifact-ledger.test.ts +++ b/src/daemon/__tests__/replay-runtime/session-replay-runtime-artifact-ledger.test.ts @@ -31,7 +31,10 @@ test('a mid-loop throw reports exactly the artifacts of the steps that ran befor const root = mkdtempForTestSync('agent-device-replay-artifact-ledger-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); // Two real files, so `collectReplayActionArtifactPaths`' existence check // keeps them (it drops any candidate path that is not a file on disk). @@ -87,7 +90,10 @@ test('a completed run reports every step’s artifacts through the threaded ledg const root = mkdtempForTestSync('agent-device-replay-artifact-ledger-ok-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); const firstArtifact = path.join(root, 'step-1.png'); const secondArtifact = path.join(root, 'step-2.png'); diff --git a/src/daemon/__tests__/replay-runtime/session-replay-runtime-binding.test.ts b/src/daemon/__tests__/replay-runtime/session-replay-runtime-binding.test.ts index 9e1273e9f9..e87d9667d7 100644 --- a/src/daemon/__tests__/replay-runtime/session-replay-runtime-binding.test.ts +++ b/src/daemon/__tests__/replay-runtime/session-replay-runtime-binding.test.ts @@ -81,7 +81,7 @@ test('typed Maestro keeps a port-only runtime digest stable after launch binds t metroPort: 8083, }); if (!request.runtime) throw new Error('open must carry effective runtime hints'); - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); sessionStore.setRuntimeHints(sessionName, request.runtime); return { ok: true, data: {} }; } diff --git a/src/daemon/__tests__/replay-runtime/session-replay-runtime-failure-response.test.ts b/src/daemon/__tests__/replay-runtime/session-replay-runtime-failure-response.test.ts index 46a45168ec..c08638c2b8 100644 --- a/src/daemon/__tests__/replay-runtime/session-replay-runtime-failure-response.test.ts +++ b/src/daemon/__tests__/replay-runtime/session-replay-runtime-failure-response.test.ts @@ -37,7 +37,7 @@ test('divergence cause and action strings pass through the central redactor at c const root = mkdtempForTestSync('agent-device-replay-divergence-redact-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['click "Save"']); mockDispatchCommand.mockRejectedValue(new Error('no device runner available')); @@ -71,7 +71,10 @@ test('a fill divergence never serializes the typed text at any response level', const root = mkdtempForTestSync('agent-device-replay-fill-leak-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); const filePath = writeReplayFile(root, [`fill 'label="Email"' ${JSON.stringify(sentinel)}`]); // Selector miss forces the divergence on the fill step; the failure // message is a realistic selector error, not an echo of the typed text. @@ -125,7 +128,10 @@ test('a capture-failed screen hint redacts a secret in the capture error', async const root = mkdtempForTestSync('agent-device-replay-screen-redact-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); const filePath = writeReplayFile(root, ['click "Save"']); // The post-failure snapshot capture throws with a secret-bearing message. mockDispatchCommand.mockRejectedValue(new Error('snapshot failed: api_key=sk-live-abc123def456')); @@ -159,7 +165,10 @@ test('an expanded ${VAR} value echoed by a selector error never reaches the publ const root = mkdtempForTestSync('agent-device-replay-var-leak-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); const filePath = writeReplayFile(root, ['press label="${SECRET}"']); mockDispatchCommand.mockRejectedValue(new Error('no device runner available')); @@ -203,7 +212,10 @@ test('an expanded built-in AD_DEVICE_ID never reaches the public divergence', as const sessionName = 'static-session-context'; const root = mkdtempForTestSync('agent-device-replay-builtin-var-leak-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); const filePath = writeReplayFile(root, ['press label="${AD_DEVICE_ID}"']); mockDispatchCommand.mockRejectedValue(new Error('no device runner available')); diff --git a/src/daemon/__tests__/replay-runtime/session-replay-runtime-failure.test.ts b/src/daemon/__tests__/replay-runtime/session-replay-runtime-failure.test.ts index d5333ef699..a8f83bc810 100644 --- a/src/daemon/__tests__/replay-runtime/session-replay-runtime-failure.test.ts +++ b/src/daemon/__tests__/replay-runtime/session-replay-runtime-failure.test.ts @@ -40,7 +40,10 @@ test('a failing replay step returns REPLAY_DIVERGENCE with cause preserved and c const root = mkdtempForTestSync('agent-device-replay-divergence-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); // The post-failure screen digest capture (and the suggestions re-resolution @@ -166,7 +169,10 @@ test('a normalized nested failure preserves typed recovery signals on REPLAY_DIV const root = mkdtempForTestSync('agent-device-replay-recovery-signals-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); const filePath = writeReplayFile(root, ['click "Save"']); mockDispatchCommand.mockRejectedValue(new Error('no device runner available')); @@ -201,7 +207,10 @@ test('a capture the runner declared sparse reaches the divergence details as a v const root = mkdtempForTestSync('agent-device-replay-sparse-verdict-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); const filePath = writeReplayFile(root, ['click "Not Now"']); mockDispatchCommand.mockRejectedValue(new Error('no device runner available')); @@ -243,7 +252,10 @@ test('a failing replay step captures an available screen digest with blessed ref const root = mkdtempForTestSync('agent-device-replay-divergence-screen-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); const filePath = writeReplayFile(root, ['click "Save"']); mockDispatchCommand.mockResolvedValue({ @@ -290,7 +302,10 @@ test('a failing replay step ranks a re-resolved suggestion when the recorded sel const root = mkdtempForTestSync('agent-device-replay-divergence-suggest-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); // The recorded selector is label-only, so it still structurally matches a // node in the fresh capture even though the underlying tap failed (e.g. the // node moved off-screen or was momentarily not hittable) — the exact class @@ -387,7 +402,7 @@ test('a failure inside a retry-wrapped runFlow include reports the include file const root = mkdtempForTestSync('agent-device-replay-retry-provenance-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const childPath = writeMaestroInclude(root); const mainPath = path.join(root, 'main.yaml'); fs.writeFileSync( @@ -444,7 +459,7 @@ test('a failure inside a runtime runFlow.when-wrapped include reports the includ const root = mkdtempForTestSync('agent-device-replay-when-provenance-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const childPath = writeMaestroInclude(root); const mainPath = path.join(root, 'main.yaml'); fs.writeFileSync( @@ -500,7 +515,7 @@ test('typed Maestro failures rank suggestions with Maestro regex selector semant const root = mkdtempForTestSync('agent-device-maestro-suggestion-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const flowPath = path.join(root, 'flow.yaml'); fs.writeFileSync( flowPath, @@ -556,7 +571,7 @@ test('typed Maestro failures never serialize input text', async () => { const root = mkdtempForTestSync('agent-device-maestro-text-redaction-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const flowPath = path.join(root, 'flow.yaml'); const typedText = 'highly-sensitive-value'; fs.writeFileSync( diff --git a/src/daemon/__tests__/replay-runtime/session-replay-runtime-keep-session.test.ts b/src/daemon/__tests__/replay-runtime/session-replay-runtime-keep-session.test.ts index 8de8149e7a..8ae4ab2f4b 100644 --- a/src/daemon/__tests__/replay-runtime/session-replay-runtime-keep-session.test.ts +++ b/src/daemon/__tests__/replay-runtime/session-replay-runtime-keep-session.test.ts @@ -53,7 +53,7 @@ test('--keep-session suppresses a close that is terminal among executable action const root = mkdtempForTestSync('agent-device-replay-keep-marker-tail-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'close', 'replay "./nested-flow.ad"']); const commands: string[] = []; @@ -64,7 +64,7 @@ test('--keep-session suppresses a close that is terminal among executable action sessionStore, invoke: async (req) => { commands.push(req.command); - if (req.command === 'close') sessionStore.delete(sessionName); + if (req.command === 'close') sessionStore.retire(sessionStore.lookup(sessionName)!); return { ok: true, data: {} }; }, }); @@ -79,7 +79,7 @@ test('--keep-session fails explicitly when the completed replay has no live sess const root = mkdtempForTestSync('agent-device-replay-keep-postcondition-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Log out"']); const response = await runReplayForTest({ @@ -88,7 +88,7 @@ test('--keep-session fails explicitly when the completed replay has no live sess logPath: path.join(root, 'daemon.log'), sessionStore, invoke: async (req) => { - if (req.command === 'click') sessionStore.delete(sessionName); + if (req.command === 'click') sessionStore.retire(sessionStore.lookup(sessionName)!); return { ok: true, data: {} }; }, }); @@ -106,7 +106,7 @@ test('--keep-session suppresses only the authored terminal close and reports the const root = mkdtempForTestSync('agent-device-replay-keep-session-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"', 'close']); const commands: string[] = []; @@ -117,7 +117,7 @@ test('--keep-session suppresses only the authored terminal close and reports the sessionStore, invoke: async (req) => { commands.push(req.command); - if (req.command === 'close') sessionStore.delete(sessionName); + if (req.command === 'close') sessionStore.retire(sessionStore.lookup(sessionName)!); return { ok: true, data: {} }; }, }); @@ -133,7 +133,7 @@ test('--keep-session preserves an interior close instead of broad command filter const root = mkdtempForTestSync('agent-device-replay-keep-interior-close-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'close', 'open "Next"']); const commands: string[] = []; @@ -156,7 +156,7 @@ test('--keep-session is a no-op for an already close-less script', async () => { const root = mkdtempForTestSync('agent-device-replay-keep-close-less-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); const invoke = vi.fn(async (_req: DaemonRequest) => ({ ok: true as const, data: {} })); @@ -178,7 +178,7 @@ test('--keep-session rejects Maestro YAML before engine dispatch', async () => { const root = mkdtempForTestSync('agent-device-replay-keep-maestro-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = path.join(root, 'flow.yaml'); fs.writeFileSync(filePath, ['appId: com.example.app', '---', '- launchApp'].join('\n')); const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); diff --git a/src/daemon/__tests__/replay-runtime/session-replay-runtime-plan.test.ts b/src/daemon/__tests__/replay-runtime/session-replay-runtime-plan.test.ts index 9ae7567682..301c8794b3 100644 --- a/src/daemon/__tests__/replay-runtime/session-replay-runtime-plan.test.ts +++ b/src/daemon/__tests__/replay-runtime/session-replay-runtime-plan.test.ts @@ -47,7 +47,7 @@ test('resume skips steps 1..from-1 without invoking them and executes only from const root = mkdtempForTestSync('agent-device-replay-resume-skip-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, [ 'open "Demo"', 'click label="Continue"', @@ -106,7 +106,7 @@ test('resume requires both --from and --plan-digest together', async () => { const root = mkdtempForTestSync('agent-device-replay-resume-pair-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); const fromOnly = await runReplayForTest({ @@ -138,7 +138,7 @@ test('resume rejects an out-of-range --from before any action', async () => { const root = mkdtempForTestSync('agent-device-replay-resume-range-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); const response = await runReplayForTest({ @@ -173,7 +173,7 @@ test("a rejected --from/--plan-digest resume never reaches prepareReplaySession' const root = mkdtempForTestSync('agent-device-replay-resume-no-mutate-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Continue"', 'click "Save"']); // Arm a repair transaction and stamp a corrective-resume watermark @@ -189,7 +189,6 @@ test("a rejected --from/--plan-digest resume never reaches prepareReplaySession' // from firing first (it needs `sessionActionsLength` to equal this), so // the rejection below is provably the plan-digest check, not a different one. armedSession.pendingRecordAndHeal = { expectedFrom: 2, actionsCountAtDivergence: 999 }; - sessionStore.set(sessionName, armedSession); const beforeView = coordinator.view(); const beforeActionsLength = sessionStore.get(sessionName)!.actions.length; @@ -223,7 +222,7 @@ test('resume rejects a stale --plan-digest after the script changed', async () = const root = mkdtempForTestSync('agent-device-replay-resume-stale-digest-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); const firstAttempt = await runReplayForTest({ @@ -270,7 +269,7 @@ test('resume rejects a digest from a different effective platform or target befo const root = mkdtempForTestSync('agent-device-replay-resume-effective-target-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, [ 'context platform=android target=tv', 'open "Demo"', @@ -326,7 +325,7 @@ test('resume rejects resuming past a retry-wrapped step in the skipped range', a const root = mkdtempForTestSync('agent-device-replay-resume-control-flow-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const mainPath = path.join(root, 'main.yaml'); fs.writeFileSync( mainPath, @@ -396,7 +395,7 @@ test('typed Maestro resume digest binds an inferred session target', async () => const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; const session = makeIosSession(sessionName); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const flowPath = path.join(root, 'flow.yaml'); fs.writeFileSync(flowPath, 'appId: com.example.app\n---\n- back\n'); @@ -416,10 +415,9 @@ test('typed Maestro resume digest binds an inferred session target', async () => resume: { from: number; planDigest: string }; }; - sessionStore.set(sessionName, { - ...session, - device: { ...session.device, target: 'tv' }, - }); + sessionStore.update(sessionStore.lookup(sessionName)!, (current) => ({ + device: { ...current.device, target: 'tv' }, + })); const resumedAttempt = await runReplayForTest({ req: baseReq({ positionals: [flowPath], @@ -447,7 +445,7 @@ test('typed Maestro rejects selectors that conflict with an active session', asy const root = mkdtempForTestSync('agent-device-maestro-session-conflict-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const flowPath = path.join(root, 'flow.yaml'); fs.writeFileSync(flowPath, 'appId: com.example.app\n---\n- back\n'); const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); @@ -573,7 +571,7 @@ test('typed Maestro resume digest binds effective stored runtime hints', async ( const root = mkdtempForTestSync('agent-device-maestro-session-runtime-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); sessionStore.setRuntimeHints(sessionName, { platform: 'ios', metroHost: '127.0.0.1', diff --git a/src/daemon/__tests__/replay-runtime/session-replay-runtime.fixtures.ts b/src/daemon/__tests__/replay-runtime/session-replay-runtime.fixtures.ts index d7d47319c0..32300cd8ed 100644 --- a/src/daemon/__tests__/replay-runtime/session-replay-runtime.fixtures.ts +++ b/src/daemon/__tests__/replay-runtime/session-replay-runtime.fixtures.ts @@ -52,6 +52,6 @@ export function seedReplayFixtureSession( sessionStore: SessionStore, platform: 'android' | 'ios' | undefined, ): void { - if (platform === 'android') sessionStore.set('s', makeAndroidSession('s')); - if (platform === 'ios') sessionStore.set('s', makeIosSession('s')); + if (platform === 'android') sessionStore.publish('s', makeAndroidSession('s')); + if (platform === 'ios') sessionStore.publish('s', makeIosSession('s')); } diff --git a/src/daemon/__tests__/replay-runtime/session-replay-runtime.test.ts b/src/daemon/__tests__/replay-runtime/session-replay-runtime.test.ts index 15f5460ac2..bdaced7b33 100644 --- a/src/daemon/__tests__/replay-runtime/session-replay-runtime.test.ts +++ b/src/daemon/__tests__/replay-runtime/session-replay-runtime.test.ts @@ -43,7 +43,7 @@ test('a successful replay prints one line with the step count and wall time', as const root = mkdtempForTestSync('agent-device-replay-success-message-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); const response = await runReplayForTest({ @@ -72,7 +72,7 @@ test('a close-less replay reports sessionActive: true (real producer, session st const root = mkdtempForTestSync('agent-device-replay-session-active-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); const response = await runReplayForTest({ @@ -93,7 +93,7 @@ test('a replay whose terminal close removes the session reports sessionActive: f const root = mkdtempForTestSync('agent-device-replay-session-closed-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'close']); const response = await runReplayForTest({ @@ -105,7 +105,7 @@ test('a replay whose terminal close removes the session reports sessionActive: f // removing the session from the store. Every other command is a no-op, // same as the rest of this file's `invoke` stubs. invoke: async (req) => { - if (req.command === 'close') sessionStore.delete(sessionName); + if (req.command === 'close') sessionStore.retire(sessionStore.lookup(sessionName)!); return { ok: true, data: {} }; }, }); @@ -120,7 +120,7 @@ test('Maestro YAML uses the typed engine while .ad remains generic', async () => const root = mkdtempForTestSync('agent-device-typed-maestro-route-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const yamlPath = path.join(root, 'flow.yaml'); fs.writeFileSync( yamlPath, @@ -169,7 +169,7 @@ test('bare Maestro YAML requires explicit --maestro routing', async () => { const root = mkdtempForTestSync('agent-device-maestro-explicit-route-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const yamlPath = path.join(root, 'flow.yaml'); fs.writeFileSync(yamlPath, 'appId: com.example.app\n---\n- launchApp\n'); @@ -194,7 +194,7 @@ test('ADR 0016 / #1384: a Maestro replay reports sessionActive: true (real produ const root = mkdtempForTestSync('agent-device-maestro-session-active-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const yamlPath = path.join(root, 'flow.yaml'); fs.writeFileSync(yamlPath, 'appId: com.example.app\n---\n- launchApp\n'); @@ -220,7 +220,7 @@ test('typed Maestro nested commands receive the runtime hints bound into the pla const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; const session = makeIosSession(sessionName); - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...session, device: { ...session.device, simulatorSetPath: '/tmp/custom-simulator-set' }, }); @@ -269,7 +269,7 @@ test('typed Maestro writes source-aware redacted step timing traces', async () = const root = mkdtempForTestSync('agent-device-maestro-step-trace-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const yamlPath = path.join(root, 'flow.yaml'); const tracePath = path.join(root, 'replay-timing.ndjson'); fs.writeFileSync(yamlPath, 'appId: com.example.app\n---\n- inputText: highly-sensitive\n'); @@ -329,7 +329,7 @@ test('replay trace failures do not change action semantics', async () => { const root = mkdtempForTestSync('agent-device-replay-trace-failure-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const yamlPath = path.join(root, 'flow.yaml'); fs.writeFileSync(yamlPath, 'appId: com.example.app\n---\n- back\n'); @@ -352,7 +352,7 @@ test('generic replay traces redact typed text', async () => { const root = mkdtempForTestSync('agent-device-replay-trace-redaction-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const secret = 'highly-sensitive-value'; const filePath = writeReplayFile(root, [`type "${secret}"`]); const tracePath = path.join(root, 'replay-timing.ndjson'); @@ -378,7 +378,7 @@ test('Maestro YAML rejects .ad repair recording before executing any command', a const root = mkdtempForTestSync('agent-device-typed-maestro-save-script-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const yamlPath = path.join(root, 'flow.yaml'); fs.writeFileSync(yamlPath, 'appId: com.example.app\n---\n- launchApp\n'); const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); @@ -407,7 +407,7 @@ test('Maestro YAML cannot append commands to an active .ad repair session', asyn const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; const session = makeRepairArmedSession(sessionName); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const yamlPath = path.join(root, 'flow.yaml'); fs.writeFileSync(yamlPath, 'appId: com.example.app\n---\n- launchApp\n'); const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); @@ -433,7 +433,7 @@ test('replay rejects legacy JSON payload files', async () => { const root = mkdtempForTestSync('agent-device-replay-json-rejected-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = path.join(root, 'replay.json'); fs.writeFileSync(filePath, JSON.stringify({ optimizedActions: [] }, null, 2)); @@ -466,7 +466,7 @@ test('replay rejects an unknown --replay-backend value before any step dispatch const root = mkdtempForTestSync('agent-device-replay-unknown-backend-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); @@ -495,7 +495,7 @@ test('replay still dispatches a plain .ad script with replayBackend: "maestro"', const root = mkdtempForTestSync('agent-device-replay-ad-maestro-backend-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); const invoke = vi.fn(async () => ({ ok: true as const, data: {} })); @@ -517,7 +517,7 @@ test('replay rejects malformed .ad lines with unclosed quotes', async () => { const root = mkdtempForTestSync('agent-device-replay-invalid-ad-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, [String.raw`click "id=\"broken\"`]); const response = await runReplayForTest({ @@ -540,7 +540,10 @@ test('--update never rewrites the .ad file, even when a re-resolvable suggestion const root = mkdtempForTestSync('agent-device-replay-update-no-write-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); const filePath = writeReplayFile(root, ['click label="Save"']); const before = fs.readFileSync(filePath, 'utf8'); const statBefore = fs.statSync(filePath); @@ -590,7 +593,7 @@ test('a successful --update replay reports healed: 0 (heal is retired, not just const root = mkdtempForTestSync('agent-device-replay-update-healed-zero-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']); const response = await runReplayForTest({ @@ -611,7 +614,7 @@ test('--update no longer refuses env directives (the guard existed only for rewr const root = mkdtempForTestSync('agent-device-replay-update-env-ok-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['env NAME=World', 'open "Demo"']); const response = await runReplayForTest({ @@ -629,7 +632,7 @@ test('--update no longer refuses ${VAR} interpolation (the guard existed only fo const root = mkdtempForTestSync('agent-device-replay-update-interp-ok-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, ['click label="${NAME}"']); const response = await runReplayForTest({ diff --git a/src/daemon/__tests__/replay-runtime/session-replay-script-source.test.ts b/src/daemon/__tests__/replay-runtime/session-replay-script-source.test.ts index 41aa965eef..555fc33bcf 100644 --- a/src/daemon/__tests__/replay-runtime/session-replay-script-source.test.ts +++ b/src/daemon/__tests__/replay-runtime/session-replay-script-source.test.ts @@ -45,7 +45,7 @@ beforeEach(() => { test('a bundled replay runs after the script file is gone from the daemon host', async () => { const root = mkdtempForTestSync('agent-device-replay-source-bundle-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); - sessionStore.set('default', makeIosSession('default')); + sessionStore.publish('default', makeIosSession('default')); const scriptPath = path.join(root, 'flow.ad'); fs.writeFileSync(scriptPath, 'open "Demo"\nclick "Save"\n'); const bundle = replayScriptSourceBundleFor(scriptPath); @@ -75,7 +75,7 @@ test('a bundled replay runs after the script file is gone from the daemon host', test('a bundled Maestro replay resolves runFlow includes from the bundle, not the daemon disk', async () => { const root = mkdtempForTestSync('agent-device-replay-maestro-bundle-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); - sessionStore.set('default', makeIosSession('default')); + sessionStore.publish('default', makeIosSession('default')); const flowPath = path.join(root, 'flow.yaml'); fs.writeFileSync(flowPath, 'appId: com.example.app\n---\n- runFlow: included.yaml\n'); fs.writeFileSync(path.join(root, 'included.yaml'), '---\n- back\n'); @@ -107,7 +107,7 @@ test('a bundled Maestro replay resolves runFlow includes from the bundle, not th test('a Maestro include missing from the bundle fails naming the include path', async () => { const root = mkdtempForTestSync('agent-device-replay-maestro-missing-include-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); - sessionStore.set('default', makeIosSession('default')); + sessionStore.publish('default', makeIosSession('default')); const flowPath = path.join(root, 'flow.yaml'); const flow = 'appId: com.example.app\n---\n- runFlow: absent.yaml\n'; fs.writeFileSync(flowPath, flow); @@ -141,7 +141,7 @@ test('a Maestro include missing from the bundle fails naming the include path', test('a request carrying only a path is refused with the client-must-send-sources message', async () => { const root = mkdtempForTestSync('agent-device-replay-path-only-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); - sessionStore.set('default', makeIosSession('default')); + sessionStore.publish('default', makeIosSession('default')); const scriptPath = path.join(root, 'flow.ad'); fs.writeFileSync(scriptPath, 'open "Demo"\n'); @@ -171,7 +171,7 @@ test('a request carrying only a path is refused with the client-must-send-source test('a failing step reports the caller-resolved script path and line', async () => { const root = mkdtempForTestSync('agent-device-replay-source-display-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); - sessionStore.set('default', makeIosSession('default')); + sessionStore.publish('default', makeIosSession('default')); const scriptPath = path.join(root, 'flow.ad'); fs.writeFileSync(scriptPath, 'open "Demo"\nclick "Nope"\n'); diff --git a/src/daemon/__tests__/replay-suite/session-test-runner.test.ts b/src/daemon/__tests__/replay-suite/session-test-runner.test.ts index 6275640088..a8630547f2 100644 --- a/src/daemon/__tests__/replay-suite/session-test-runner.test.ts +++ b/src/daemon/__tests__/replay-suite/session-test-runner.test.ts @@ -31,7 +31,7 @@ vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) test('session_list includes device_udid and ios_simulator_device_set for iOS sessions', async () => { const sessionStore = makeSessionStore(); - sessionStore.set( + sessionStore.publish( 'ios-scoped', makeSession('ios-scoped', { platform: 'apple', @@ -42,7 +42,7 @@ test('session_list includes device_udid and ios_simulator_device_set for iOS ses simulatorSetPath: '/tmp/tenant-a/simulators', }), ); - sessionStore.set( + sessionStore.publish( 'android-1', makeSession('android-1', { platform: 'android', @@ -52,7 +52,7 @@ test('session_list includes device_udid and ios_simulator_device_set for iOS ses booted: true, }), ); - sessionStore.set( + sessionStore.publish( 'macos-1', makeSession('macos-1', { platform: 'apple', @@ -220,7 +220,7 @@ test('test cleans up suite-owned sessions after each executed script', async () logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), sessionStore, invoke: async (req) => { - sessionStore.set( + sessionStore.publish( req.session, makeSession(req.session, { platform: 'android', diff --git a/src/daemon/__tests__/replay-suite/session-test-suite-command-video.test.ts b/src/daemon/__tests__/replay-suite/session-test-suite-command-video.test.ts index da008d36ab..2b68cd939a 100644 --- a/src/daemon/__tests__/replay-suite/session-test-suite-command-video.test.ts +++ b/src/daemon/__tests__/replay-suite/session-test-suite-command-video.test.ts @@ -136,7 +136,6 @@ function startMockRecording(params: { metadata: { phase: 'active' }, }), }; - sessionStore.set(req.session, session); } return { ok: true, data: { recording: 'started', outPath: state.recordingPath } }; } @@ -152,7 +151,6 @@ async function stopMockRecording(params: { if (session) { await session.screenRecording?.handle.finish(); session.screenRecording = undefined; - sessionStore.set(req.session, session); state.liveSlotCleared = sessionStore.get(req.session)?.screenRecording === undefined; } fs.writeFileSync(state.recordingPath, 'video'); @@ -290,7 +288,7 @@ test('test finalizes replay video exactly once when cancellation arrives after s nestedRequests.push(nestedReq); if (nestedReq.command === 'open') { const provisionalSession = makeIosSession(nestedReq.session); - sessionStore.set(nestedReq.session, provisionalSession); + sessionStore.publish(nestedReq.session, provisionalSession); const hookResponse = await nestedReq.internal?.openLifecycle?.beforeDispatch?.(); if (hookResponse && !hookResponse.ok) return hookResponse; events.push('open:dispatch'); diff --git a/src/daemon/__tests__/replay-suite/session-test-suite.test.ts b/src/daemon/__tests__/replay-suite/session-test-suite.test.ts index 13438a94ef..443cda9c61 100644 --- a/src/daemon/__tests__/replay-suite/session-test-suite.test.ts +++ b/src/daemon/__tests__/replay-suite/session-test-suite.test.ts @@ -354,7 +354,7 @@ test('test stops before retrying when a rejected close leaves the prior macOS se }, invoke: async (req) => { replayAttempts += 1; - sessionStore.set(req.session, makeMacOsSession(req.session)); + sessionStore.publish(req.session, makeMacOsSession(req.session)); return { ok: false, error: { code: 'COMMAND_FAILED', message: 'open "System Settings" failed' }, @@ -497,7 +497,7 @@ test('test aggregates snapshot diagnostics from replay session samples', async ( backend: 'android', platform: 'android', }); - sessionStore.set(req.session, session); + sessionStore.publish(req.session, session); return { ok: true, data: { replayed: 1, healed: 0 } }; }, }); @@ -554,7 +554,7 @@ test('test aggregates snapshot diagnostics from failed replay session samples', backend: 'android', platform: 'android', }); - sessionStore.set(req.session, session); + sessionStore.publish(req.session, session); return { ok: false, error: { code: 'COMMAND_FAILED', message: 'open failed' }, diff --git a/src/daemon/__tests__/replay-target/session-replay-dispatch-selector-miss.test.ts b/src/daemon/__tests__/replay-target/session-replay-dispatch-selector-miss.test.ts index fa550aaa33..2fee6c82cc 100644 --- a/src/daemon/__tests__/replay-target/session-replay-dispatch-selector-miss.test.ts +++ b/src/daemon/__tests__/replay-target/session-replay-dispatch-selector-miss.test.ts @@ -64,7 +64,10 @@ beforeEach(() => { function setupSession(root: string): { sessionStore: SessionStore; sessionName: string } { const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); return { sessionStore, sessionName }; } diff --git a/src/daemon/__tests__/replay-target/session-replay-scenario.fixtures.ts b/src/daemon/__tests__/replay-target/session-replay-scenario.fixtures.ts index f129fc90fb..91d4f070ab 100644 --- a/src/daemon/__tests__/replay-target/session-replay-scenario.fixtures.ts +++ b/src/daemon/__tests__/replay-target/session-replay-scenario.fixtures.ts @@ -22,7 +22,7 @@ function iosReplayScene(prefix: string): ReplaySessionScene { const root = mkdtempForTestSync(prefix); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosAppSession(sessionName)); + sessionStore.publish(sessionName, makeIosAppSession(sessionName)); return { root, sessionStore, sessionName, logPath: path.join(root, 'daemon.log') }; } diff --git a/src/daemon/__tests__/replay-target/session-replay-selector-routes.test.ts b/src/daemon/__tests__/replay-target/session-replay-selector-routes.test.ts index 1290acc40b..a30e64b37c 100644 --- a/src/daemon/__tests__/replay-target/session-replay-selector-routes.test.ts +++ b/src/daemon/__tests__/replay-target/session-replay-selector-routes.test.ts @@ -39,7 +39,7 @@ test('replay executes selector reads before reporting a covered-target divergenc const root = mkdtempForTestSync('agent-device-replay-selector-routes-'); const sessionName = 'default'; const sessionStore = new SessionStore(path.join(root, 'sessions')); - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); const filePath = writeReplayFile(root, [ 'open "Demo"', String.raw`get text "id=\"field-name\""`, diff --git a/src/daemon/__tests__/request-dispatch-disclosure.test.ts b/src/daemon/__tests__/request-dispatch-disclosure.test.ts index 9ecc449a4b..24a5233d3a 100644 --- a/src/daemon/__tests__/request-dispatch-disclosure.test.ts +++ b/src/daemon/__tests__/request-dispatch-disclosure.test.ts @@ -122,7 +122,7 @@ async function route( androidObservation: AndroidObservationAdapter = clearAndroidObservationFixture, ) { const sessionStore = makeSessionStore('agent-device-dispatch-route-'); - sessionStore.set(SESSION, session); + sessionStore.publish(SESSION, session); const handler = createRequestHandler({ logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), token: 'test-token', diff --git a/src/daemon/__tests__/request-execution-scope-lease-expiry.test.ts b/src/daemon/__tests__/request-execution-scope-lease-expiry.test.ts index 16d48f801d..744997ecfe 100644 --- a/src/daemon/__tests__/request-execution-scope-lease-expiry.test.ts +++ b/src/daemon/__tests__/request-execution-scope-lease-expiry.test.ts @@ -27,7 +27,7 @@ test('expired leases remove owned sessions before the next command and free capa now: () => now, }); const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' }); - sessionStore.set( + sessionStore.publish( 'default', makeSession('default', { device: LINUX_DEVICE, diff --git a/src/daemon/__tests__/request-execution-scope-open-device-wait.test.ts b/src/daemon/__tests__/request-execution-scope-open-device-wait.test.ts index c623e1f707..b533eedfd2 100644 --- a/src/daemon/__tests__/request-execution-scope-open-device-wait.test.ts +++ b/src/daemon/__tests__/request-execution-scope-open-device-wait.test.ts @@ -91,6 +91,7 @@ function openOnUncontendedDevice( name: string, sessionStore: ReturnType, order: string[], + releaseAfterMs?: number, ): Promise { return scope.runLocked(async () => { const currentOwner = sessionStore.findByDevice(CONTESTED_DEVICE.id); @@ -98,7 +99,8 @@ function openOnUncontendedDevice( return `refused:${currentOwner.address}`; } - sessionStore.set(name, sessionOnDevice(name)); + const ref = sessionStore.publish(name, sessionOnDevice(name)); + if (releaseAfterMs !== undefined) setTimeout(() => sessionStore.retire(ref), releaseAfterMs); order.push(name); return `opened:${name}`; }); @@ -107,11 +109,10 @@ function openOnUncontendedDevice( test('an open that lost the race to a free device re-waits and opens rather than refusing', async () => { const sessionStore = makeSessionStore('agent-device-open-wait-race-'); const leaseRegistry = new LeaseRegistry(); - sessionStore.set('holder', sessionOnDevice('holder')); + const holder = sessionStore.publish('holder', sessionOnDevice('holder')); // The holder lets go shortly after; the first open to bind the device lets go again after that, // the way any session closed by its owner would. - setTimeout(() => sessionStore.delete('holder'), 50); - setTimeout(() => sessionStore.delete('first-opener'), 400); + setTimeout(() => sessionStore.retire(holder), 50); const first = await createRequestExecutionScope({ req: openRequest('first-opener'), @@ -128,7 +129,7 @@ test('an open that lost the race to a free device re-waits and opens rather than const startedAtMs = Date.now(); // Each open binds a device to its own session under its own device lock, which is what a real // open does. The second can therefore only get in once the first releases it. - const firstOpened = openOnUncontendedDevice(first, 'first-opener', sessionStore, order); + const firstOpened = openOnUncontendedDevice(first, 'first-opener', sessionStore, order, 350); const secondOpened = openOnUncontendedDevice(second, 'second-opener', sessionStore, order); await expect(firstOpened).resolves.toBe('opened:first-opener'); @@ -147,7 +148,7 @@ test('an open that lost the race to a free device re-waits and opens rather than test('a close that frees the device mid-wait gets through while the open is waiting', async () => { const sessionStore = makeSessionStore('agent-device-open-wait-close-'); const leaseRegistry = new LeaseRegistry(); - sessionStore.set('holder', sessionOnDevice('holder')); + const holder = sessionStore.publish('holder', sessionOnDevice('holder')); const order: string[] = []; const opened = createRequestExecutionScope({ @@ -157,7 +158,7 @@ test('a close that frees the device mid-wait gets through while the open is wait }).then((scope) => scope.runLocked(async () => { order.push('open-bound'); - sessionStore.set('waiter', sessionOnDevice('waiter')); + sessionStore.publish('waiter', sessionOnDevice('waiter')); return 'opened'; }), ); @@ -172,7 +173,7 @@ test('a close that frees the device mid-wait gets through while the open is wait await expect( closer.runLocked(async () => { order.push('close-freed-the-device'); - sessionStore.delete('holder'); + sessionStore.retire(holder); return 'closed'; }), ).resolves.toBe('closed'); diff --git a/src/daemon/__tests__/request-execution-scope.test.ts b/src/daemon/__tests__/request-execution-scope.test.ts index a4f0ce5a32..8c6fe6c221 100644 --- a/src/daemon/__tests__/request-execution-scope.test.ts +++ b/src/daemon/__tests__/request-execution-scope.test.ts @@ -194,7 +194,7 @@ test('leased session admission uses stored lease metadata and heartbeats', async clientId: 'client-a', deviceKey: 'ios:sim-1', }); - sessionStore.set( + sessionStore.publish( 'default', makeIosSession('default', { lease: { @@ -237,7 +237,7 @@ test('leased session heartbeat is serialized with the request execution lock', a clientId: 'client-a', deviceKey: 'ios:sim-1', }); - sessionStore.set( + sessionStore.publish( 'default', makeIosSession('default', { lease: { @@ -293,7 +293,7 @@ test('leased session heartbeat is serialized with the request execution lock', a test('a later external command cannot interleave with replay observation finalization', async () => { const sessionStore = makeSessionStore('agent-device-request-scope-'); - sessionStore.set('default', makeIosSession('default')); + sessionStore.publish('default', makeIosSession('default')); const leaseRegistry = new LeaseRegistry(); const replay = await createRequestExecutionScope({ req: makeRequest({ command: 'replay' }), @@ -350,7 +350,7 @@ test('a fresh replay keeps its session lock after a nested open binds the device const replayRun = replay.runLocked( async () => await new Promise((resolve) => { - sessionStore.set('default', makeIosSession('default')); + sessionStore.publish('default', makeIosSession('default')); finishReplay = resolve; sessionOpened(); }), @@ -379,7 +379,7 @@ test('leased session rejects mismatched lease id before dispatch', async () => { const sessionStore = makeSessionStore('agent-device-request-scope-'); const leaseRegistry = new LeaseRegistry(); const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' }); - sessionStore.set( + sessionStore.publish( 'default', makeIosSession('default', { lease: { @@ -410,7 +410,7 @@ test.each([ const sessionStore = makeSessionStore('agent-device-request-scope-'); const leaseRegistry = new LeaseRegistry(); const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' }); - sessionStore.set( + sessionStore.publish( 'default', makeIosSession('default', { lease: { @@ -438,7 +438,7 @@ test.each([ test('local unleased session admission still succeeds', async () => { const sessionStore = makeSessionStore('agent-device-request-scope-'); - sessionStore.set('default', makeIosSession('default')); + sessionStore.publish('default', makeIosSession('default')); const scope = await createRequestExecutionScope({ req: makeRequest({ command: 'snapshot' }), @@ -451,7 +451,7 @@ test('local unleased session admission still succeeds', async () => { test('local unleased session ignores stale lease id without tenant scope', async () => { const sessionStore = makeSessionStore('agent-device-request-scope-'); - sessionStore.set('default', makeIosSession('default')); + sessionStore.publish('default', makeIosSession('default')); const scope = await createRequestExecutionScope({ req: makeRequest({ command: 'snapshot', @@ -473,7 +473,7 @@ test('provider lease admission succeeds without a device key', async () => { leaseBackend: 'android-instance', leaseProvider: 'limrun', }); - sessionStore.set( + sessionStore.publish( 'default', makeAndroidSession('default', { lease: { @@ -509,7 +509,7 @@ test('an admitted request that outlives the lease TTL keeps its lease and sessio now: () => now, }); const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' }); - sessionStore.set( + sessionStore.publish( 'default', makeIosSession('default', { lease: { @@ -550,7 +550,7 @@ test('expired leased session cleanup waits for the request execution lock', asyn now: () => now, }); const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' }); - sessionStore.set( + sessionStore.publish( 'default', makeIosSession('default', { lease: { @@ -641,7 +641,7 @@ test('tenant lease rejection flushes diagnostics into the effective session requ test('prepareLockedRequestScope preserves existing-session selector validation', async () => { const sessionStore = makeSessionStore('agent-device-request-scope-'); - sessionStore.set('default', makeAndroidSession('default')); + sessionStore.publish('default', makeAndroidSession('default')); const scope = await createRequestExecutionScope({ req: makeRequest({ command: 'snapshot', @@ -672,7 +672,7 @@ test('prepareLockedRequestScope blocks commands for invalidated recordings befor showTouches: true, invalidatedReason: 'iOS runner session restarted during recording', }); - sessionStore.set('default', session); + sessionStore.publish('default', session); const scope = await createRequestExecutionScope({ req: makeRequest({ command: 'snapshot' }), sessionStore, @@ -700,7 +700,7 @@ test('prepareLockedRequestScope blocks commands for invalidated recordings befor test('prepareLockedRequestScope passes the session runner log path into handler context', async () => { const sessionStore = makeSessionStore('agent-device-request-scope-'); - sessionStore.set('default', makeIosSession('default')); + sessionStore.publish('default', makeIosSession('default')); const scope = await createRequestExecutionScope({ req: makeRequest({ command: 'snapshot' }), sessionStore, @@ -723,7 +723,7 @@ test('prepareLockedRequestScope passes the session runner log path into handler test('prepareLockedRequestScope streams ordinary diagnostics into the active trace', async () => { const sessionStore = makeSessionStore('agent-device-request-scope-'); const tracePath = path.join(TEST_ROOT, 'active-session.trace'); - sessionStore.set( + sessionStore.publish( 'default', makeIosSession('default', { trace: { outPath: tracePath, startedAt: Date.now() }, @@ -767,7 +767,7 @@ test('runLocked rejects a canceled request before executing work', async () => { test('runLocked rejects a request canceled while waiting for its execution lock', async () => { const requestId = 'request-scope-canceled-after-lock'; const sessionStore = makeSessionStore('agent-device-request-scope-'); - sessionStore.set('default', makeIosSession('default')); + sessionStore.publish('default', makeIosSession('default')); const leaseRegistry = new LeaseRegistry(); const first = await createRequestExecutionScope({ req: makeRequest({ command: 'click' }), @@ -867,7 +867,7 @@ test('router: deferred tenant connect still refuses an app-target close before d // run-scoped), so a missing lease field alone is not proof of ownership. test('router: an existing lease-less session under tenant isolation still refuses close', async () => { const sessionStore = makeSessionStore('agent-device-request-scope-'); - sessionStore.set('tenant-a:default', makeIosSession('tenant-a:default')); + sessionStore.publish('tenant-a:default', makeIosSession('tenant-a:default')); const leaseRegistry = new LeaseRegistry(); const scope = await createRequestExecutionScope({ @@ -906,11 +906,11 @@ async function createScopeAcrossTwoImplicitWorkspaceSessions(command: string) { const scope = resolveSessionScope({ ...makeRequest({ command }), meta: { cwd: root } }); if (scope.kind !== 'cwd') throw new Error('expected a cwd session scope'); const sessionStore = makeSessionStore('agent-device-request-scope-ambiguity-'); - sessionStore.set( + sessionStore.publish( `cwd:${scope.id}:ios`, makeIosSession('default', { sessionScope: { kind: 'cwd', id: scope.id } }), ); - sessionStore.set( + sessionStore.publish( `cwd:${scope.id}:android`, makeAndroidSession('default', { sessionScope: { kind: 'cwd', id: scope.id } }), ); diff --git a/src/daemon/__tests__/request-handler-chain-provider-scope.test.ts b/src/daemon/__tests__/request-handler-chain-provider-scope.test.ts index cbf61f271b..52cbdf6e35 100644 --- a/src/daemon/__tests__/request-handler-chain-provider-scope.test.ts +++ b/src/daemon/__tests__/request-handler-chain-provider-scope.test.ts @@ -37,7 +37,7 @@ function makeRequest(command: string, sessionName: string): DaemonRequest { function baseChainParams(sessionName: string) { const sessionStore = makeSessionStore('agent-device-provider-scope-'); - sessionStore.set(sessionName, makeIosSession(sessionName)); + sessionStore.publish(sessionName, makeIosSession(sessionName)); return { req: makeRequest(INTERNAL_COMMANDS.runtime, sessionName), sessionName, diff --git a/src/daemon/__tests__/request-handler-chain.test.ts b/src/daemon/__tests__/request-handler-chain.test.ts index c6ce269e2d..ee766f96b5 100644 --- a/src/daemon/__tests__/request-handler-chain.test.ts +++ b/src/daemon/__tests__/request-handler-chain.test.ts @@ -40,7 +40,7 @@ function makeRequest(command: string, positionals: string[] = []): DaemonRequest function makeChainParams(req: DaemonRequest) { const sessionStore = makeSessionStore('agent-device-request-chain-'); - sessionStore.set('chain-test', makeIosSession('chain-test')); + sessionStore.publish('chain-test', makeIosSession('chain-test')); return { req, sessionName: 'chain-test', @@ -244,7 +244,7 @@ test('swipe rejects repetition inputs that can monopolize the request', async () test('duration-less public coordinate swipe retains Linux drag behavior', async () => { const sessionStore = makeSessionStore('agent-device-linux-swipe-'); - sessionStore.set('linux-swipe', makeSession('linux-swipe', { device: LINUX_DEVICE })); + sessionStore.publish('linux-swipe', makeSession('linux-swipe', { device: LINUX_DEVICE })); const drags: number[][] = []; let captureCount = 0; const provider = await createLocalLinuxToolProvider({ diff --git a/src/daemon/__tests__/request-router-android-modal.test.ts b/src/daemon/__tests__/request-router-android-modal.test.ts index 4cd127c611..0320d46bc9 100644 --- a/src/daemon/__tests__/request-router-android-modal.test.ts +++ b/src/daemon/__tests__/request-router-android-modal.test.ts @@ -151,7 +151,7 @@ test('generic Android gesture commands dismiss blocking system dialogs during re gestureRuntimeSpies.scrollDirection.mockClear(); const sessionStore = makeSessionStore('agent-device-router-android-modal-'); - sessionStore.set('default', makeAndroidSession('default')); + sessionStore.publish('default', makeAndroidSession('default')); const { openAndroidApp } = await import('@agent-device/platform-android/mechanics'); @@ -202,7 +202,7 @@ test('generic Android gesture commands continue when recording dialog inspection }); const sessionStore = makeSessionStore('agent-device-router-android-modal-'); - sessionStore.set('default', makeAndroidSession('default')); + sessionStore.publish('default', makeAndroidSession('default')); const { openAndroidApp } = await import('@agent-device/platform-android/mechanics'); vi.mocked(openAndroidApp).mockClear(); @@ -254,7 +254,7 @@ test('generic Android gesture commands skip local dialog recovery for provider d const sessionStore = makeSessionStore('agent-device-router-android-modal-provider-'); const session = makeAndroidSession('default'); - sessionStore.set('default', session); + sessionStore.publish('default', session); const runtime: ProviderDeviceRuntime = { provider: 'webdriver-fake', diff --git a/src/daemon/__tests__/request-router-android-perf.test.ts b/src/daemon/__tests__/request-router-android-perf.test.ts index 4f9ec43836..21608bbb8e 100644 --- a/src/daemon/__tests__/request-router-android-perf.test.ts +++ b/src/daemon/__tests__/request-router-android-perf.test.ts @@ -15,7 +15,7 @@ import { function makeAndroidSessionStore(name: string): SessionStore { const sessionStore = new SessionStore(`/tmp/${name}`); - sessionStore.set('default', { + sessionStore.publish('default', { name: 'default', createdAt: Date.now(), device: { diff --git a/src/daemon/__tests__/request-router-android-snapshot-helper.test.ts b/src/daemon/__tests__/request-router-android-snapshot-helper.test.ts index e4f936007e..3bb4b23ce4 100644 --- a/src/daemon/__tests__/request-router-android-snapshot-helper.test.ts +++ b/src/daemon/__tests__/request-router-android-snapshot-helper.test.ts @@ -14,7 +14,7 @@ import { function makeAndroidSessionStore(name: string): SessionStore { const sessionStore = new SessionStore(`/tmp/${name}`); - sessionStore.set('default', { + sessionStore.publish('default', { name: 'default', createdAt: Date.now(), device: { diff --git a/src/daemon/__tests__/request-router-artifacts-web.test.ts b/src/daemon/__tests__/request-router-artifacts-web.test.ts index 14e74b3dc2..0923d10c49 100644 --- a/src/daemon/__tests__/request-router-artifacts-web.test.ts +++ b/src/daemon/__tests__/request-router-artifacts-web.test.ts @@ -19,7 +19,7 @@ import { mkdtempForTestSync } from '../../__tests__/test-utils/tmp-dir.ts'; // device is web, the same round trip `request-router-events.test.ts` already proves for `events`. test('artifacts lists a daemon-tracked artifact produced during a web session', async () => { const sessionStore = makeSessionStore('agent-device-router-artifacts-web-'); - sessionStore.set('web-session', makeSession('web-session', { device: WEB_DESKTOP_DEVICE })); + sessionStore.publish('web-session', makeSession('web-session', { device: WEB_DESKTOP_DEVICE })); const artifactDir = mkdtempForTestSync('agent-device-router-artifacts-web-file-'); const artifactPath = path.join(artifactDir, 'web-smoke.png'); fs.writeFileSync(artifactPath, 'fixture-bytes'); diff --git a/src/daemon/__tests__/request-router-cost.test.ts b/src/daemon/__tests__/request-router-cost.test.ts index 8eeb772cf2..df934bbb85 100644 --- a/src/daemon/__tests__/request-router-cost.test.ts +++ b/src/daemon/__tests__/request-router-cost.test.ts @@ -85,7 +85,7 @@ beforeEach(() => { test('(a) flag-off identity: meta.includeCost absent === no meta at all, byte-identical and no cost', async () => { const { sessionStore, handler } = makeHandler(); - sessionStore.set('cost-session', makeIosSession('cost-session')); + sessionStore.publish('cost-session', makeIosSession('cost-session')); const respNoMeta = await handler(baseRequest()); const respMetaWithoutCost = await handler(baseRequest({ meta: {} })); @@ -107,7 +107,7 @@ test('(a) flag-off identity: meta.includeCost absent === no meta at all, byte-id test('(b) flag-on additive-only: cost block is the ONLY delta vs flag-off', async () => { const { sessionStore, handler } = makeHandler(); - sessionStore.set('cost-session', makeIosSession('cost-session')); + sessionStore.publish('cost-session', makeIosSession('cost-session')); const respFlagOff = await handler(baseRequest()); const respFlagOn = await handler(baseRequest({ meta: { includeCost: true } })); @@ -134,7 +134,7 @@ test('(b) flag-on additive-only: cost block is the ONLY delta vs flag-off', asyn test('(c) runnerRoundTrips counts real iOS-runner round-trip diagnostics in scope', async () => { const { sessionStore, handler } = makeHandler(); - sessionStore.set('cost-session', makeIosSession('cost-session')); + sessionStore.publish('cost-session', makeIosSession('cost-session')); // The bound operation runs inside the request's diagnostics scope, so emitting here is // equivalent to the runner-session emitting these phases per round-trip. @@ -157,7 +157,7 @@ test('(c) runnerRoundTrips counts real iOS-runner round-trip diagnostics in scop test('(c2) nodeCount reports the node-tree size whenever data carries a nodes array, additive-only', async () => { const { sessionStore, handler } = makeHandler(); - sessionStore.set('cost-session', makeIosSession('cost-session')); + sessionStore.publish('cost-session', makeIosSession('cost-session')); // The nodeCount read is command-agnostic: it triggers on any response.data that // carries a `nodes` array (in production only the snapshot node-tree commands @@ -191,7 +191,7 @@ test('(c2) nodeCount reports the node-tree size whenever data carries a nodes ar test('(d) error path: a failing request with includeCost:true produces NO cost', async () => { const { sessionStore, handler } = makeHandler(); - sessionStore.set('cost-session', makeIosSession('cost-session')); + sessionStore.publish('cost-session', makeIosSession('cost-session')); // Conflicting explicit selector under a reject lock policy fails before the bound execution. const failingRequest = baseRequest({ diff --git a/src/daemon/__tests__/request-router-events.test.ts b/src/daemon/__tests__/request-router-events.test.ts index 6db32722a2..1c4e7a8d32 100644 --- a/src/daemon/__tests__/request-router-events.test.ts +++ b/src/daemon/__tests__/request-router-events.test.ts @@ -180,7 +180,7 @@ test('events flushes pending event writes before reading', async () => { // every other platform. test('events reads the daemon-owned session timeline for a web-backed session', async () => { const sessionStore = makeSessionStore('agent-device-router-events-web-'); - sessionStore.set('web-session', makeSession('web-session', { device: WEB_DESKTOP_DEVICE })); + sessionStore.publish('web-session', makeSession('web-session', { device: WEB_DESKTOP_DEVICE })); sessionStore.recordEvent('web-session', { kind: 'action.recorded', command: 'click', @@ -218,7 +218,7 @@ test('events reads the daemon-owned session timeline for a web-backed session', test('request timeline records thrown request failures after scope creation', async () => { const sessionStore = makeSessionStore('agent-device-router-events-throws-'); - sessionStore.set('events-session', makeIosSession('events-session')); + sessionStore.publish('events-session', makeIosSession('events-session')); const handler = createRequestHandler({ logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), diff --git a/src/daemon/__tests__/request-router-lock-policy.test.ts b/src/daemon/__tests__/request-router-lock-policy.test.ts index 45da103c58..d1debf25b5 100644 --- a/src/daemon/__tests__/request-router-lock-policy.test.ts +++ b/src/daemon/__tests__/request-router-lock-policy.test.ts @@ -122,7 +122,7 @@ function installGatedDispatch(): { test('direct daemon requests cannot bypass reject lock policy for existing sessions', async () => { const sessionStore = makeSessionStore('agent-device-router-lock-'); - sessionStore.set('qa-ios', makeIosSession('qa-ios')); + sessionStore.publish('qa-ios', makeIosSession('qa-ios')); const handler = createRequestHandler({ logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), @@ -455,7 +455,7 @@ test('fresh named sessions reject incompatible selector combinations before bind test('batch steps cannot bypass reject lock policy on nested direct requests', async () => { const sessionStore = makeSessionStore('agent-device-router-lock-'); - sessionStore.set('qa-ios', makeIosSession('qa-ios')); + sessionStore.publish('qa-ios', makeIosSession('qa-ios')); const handler = createRequestHandler({ logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), @@ -499,7 +499,7 @@ test('batch steps cannot bypass reject lock policy on nested direct requests', a test('direct daemon requests apply strip lock policy for existing sessions before dispatch', async () => { const sessionStore = makeSessionStore('agent-device-router-lock-'); - sessionStore.set('qa-ios', makeIosSession('qa-ios')); + sessionStore.publish('qa-ios', makeIosSession('qa-ios')); systemRuntimeSpies.appSwitcher.mockClear(); const handler = createRequestHandler({ @@ -538,7 +538,7 @@ test('strip lock policy still refuses a request naming a different device, befor // The wrong-device footgun: `strip` used to delete --udid and run the command against the bound // session's device instead. A request that names another device must fail, not silently retarget. const sessionStore = makeSessionStore('agent-device-router-lock-'); - sessionStore.set('qa-ios', makeIosSession('qa-ios')); + sessionStore.publish('qa-ios', makeIosSession('qa-ios')); let dispatchCalls = 0; legacyDispatchCapture.mockImplementation(async () => { dispatchCalls += 1; @@ -575,7 +575,7 @@ test('strip lock policy still refuses a request naming a different device, befor test('batch preserves tenant-scoped session names across nested requests', async () => { const sessionStore = makeSessionStore('agent-device-router-lock-'); - sessionStore.set('tenant-a:default', makeIosSession('tenant-a:default')); + sessionStore.publish('tenant-a:default', makeIosSession('tenant-a:default')); const leaseRegistry = new LeaseRegistry(); const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', diff --git a/src/daemon/__tests__/request-router-open.test.ts b/src/daemon/__tests__/request-router-open.test.ts index ec084f7c75..d182e86469 100644 --- a/src/daemon/__tests__/request-router-open.test.ts +++ b/src/daemon/__tests__/request-router-open.test.ts @@ -402,7 +402,7 @@ test('close releases the session lease', async () => { runId: 'run-1', clientId: 'client-a', }); - sessionStore.set('default', { + sessionStore.publish('default', { name: 'default', device: makeIosDevice('SIM-CLOSE'), createdAt: Date.now(), @@ -432,7 +432,7 @@ test('close releases the session lease', async () => { test('close fails synchronously when root composition omits platform resource cleanup', async () => { const sessionStore = makeSessionStore('agent-device-router-open-'); - sessionStore.set('default', { + sessionStore.publish('default', { name: 'default', device: makeIosDevice('SIM-CLOSE-MISSING-CLEANUP'), createdAt: Date.now(), @@ -475,7 +475,7 @@ test('close rejects a different client before cleanup', async () => { runId: 'run-1', clientId: 'client-a', }); - sessionStore.set('default', { + sessionStore.publish('default', { name: 'default', device: makeIosDevice('SIM-CLOSE-CLIENT'), createdAt: Date.now(), diff --git a/src/daemon/__tests__/request-router-record-runtime-lock.test.ts b/src/daemon/__tests__/request-router-record-runtime-lock.test.ts index 3355a9594d..942ee36e1c 100644 --- a/src/daemon/__tests__/request-router-record-runtime-lock.test.ts +++ b/src/daemon/__tests__/request-router-record-runtime-lock.test.ts @@ -75,7 +75,7 @@ test.each([ async (device) => { const sessionName = `record-${device.platform}-unsupported`; const sessionStore = makeSessionStore(`request-router-record-${device.platform}-unsupported-`); - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { name: sessionName, device, createdAt: 1, diff --git a/src/daemon/__tests__/request-router-recording-health.test.ts b/src/daemon/__tests__/request-router-recording-health.test.ts index 1b6be6aa8f..b46d1930f9 100644 --- a/src/daemon/__tests__/request-router-recording-health.test.ts +++ b/src/daemon/__tests__/request-router-recording-health.test.ts @@ -53,7 +53,7 @@ test('router blocks non-record commands when recording was invalidated', async ( startedAt: Date.now() - 1_000, invalidatedReason: 'iOS runner session restarted during recording', }); - sessionStore.set('default', session); + sessionStore.publish('default', session); const handler = createRequestHandler({ logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), @@ -104,7 +104,7 @@ test('router allows canonical iOS simulator gestures during overlay recording af startedAt: Date.now() - 1_000, runnerSessionId: 'runner-before', }); - sessionStore.set('default', session); + sessionStore.publish('default', session); mockObserveRunnerSession.mockResolvedValue({ alive: true, sessionId: 'runner-after', diff --git a/src/daemon/__tests__/request-router-replay-scope.test.ts b/src/daemon/__tests__/request-router-replay-scope.test.ts index 5413f916f7..9e1bf571a6 100644 --- a/src/daemon/__tests__/request-router-replay-scope.test.ts +++ b/src/daemon/__tests__/request-router-replay-scope.test.ts @@ -67,7 +67,7 @@ test('replay runs active-session actions inside the parent request provider scop const replayPath = path.join(root, 'flow.ad'); fs.writeFileSync(replayPath, 'app-switcher\nscroll down\n'); const sessionStore = makeSessionStore('agent-device-replay-scope-'); - sessionStore.set('default', makeIosSession('default', { appBundleId: 'com.example.app' })); + sessionStore.publish('default', makeIosSession('default', { appBundleId: 'com.example.app' })); const appleRunnerProvider = vi.fn(() => undefined); const handler = createRequestHandler({ @@ -105,7 +105,7 @@ test('replay routes session-changing actions through the full request path', asy const replayPath = path.join(root, 'flow.ad'); fs.writeFileSync(replayPath, 'runtime set --platform ios --metro-host localhost\napp-switcher\n'); const sessionStore = makeSessionStore('agent-device-replay-full-route-'); - sessionStore.set('default', makeIosSession('default', { appBundleId: 'com.example.app' })); + sessionStore.publish('default', makeIosSession('default', { appBundleId: 'com.example.app' })); const appleRunnerProvider = vi.fn(() => undefined); const handler = createRequestHandler({ @@ -231,7 +231,7 @@ test('fresh replay retains a dynamically selected device through finalization', }); await readinessEntered; - sessionStore.set('external', makeIosSession('external')); + sessionStore.publish('external', makeIosSession('external')); let externalRequestSettled = false; const externalResponse = handler({ token: 'test-token', diff --git a/src/daemon/__tests__/request-router-response-level.test.ts b/src/daemon/__tests__/request-router-response-level.test.ts index c7fcbffb55..82e89aa411 100644 --- a/src/daemon/__tests__/request-router-response-level.test.ts +++ b/src/daemon/__tests__/request-router-response-level.test.ts @@ -64,7 +64,7 @@ function makeIosSession(name: string): SessionState { function makeHandler() { const sessionStore = makeSessionStore('agent-device-router-level-'); - sessionStore.set('level-session', makeIosSession('level-session')); + sessionStore.publish('level-session', makeIosSession('level-session')); return { sessionStore, handler: createRequestHandler({ diff --git a/src/daemon/__tests__/request-router-screenshot.test.ts b/src/daemon/__tests__/request-router-screenshot.test.ts index 2e6a8229be..db017bd1cd 100644 --- a/src/daemon/__tests__/request-router-screenshot.test.ts +++ b/src/daemon/__tests__/request-router-screenshot.test.ts @@ -75,7 +75,7 @@ function screenshotRouter( options: ScreenshotRuntimeFixtureOptions = {}, ): ScreenshotRouter { const sessionStore = makeSessionStore('agent-device-router-screenshot-'); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const runtime = screenshotRuntimeFixture(options); const handler = createRequestHandler({ logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'), @@ -202,8 +202,8 @@ test('click forwards macOS menubar session surface to the bound runtime', async test('router serializes concurrent commands for the same device across sessions', async () => { const sessionStore = makeSessionStore('agent-device-router-screenshot-'); - sessionStore.set('session-a', makeSession('session-a')); - sessionStore.set('session-b', makeSession('session-b')); + sessionStore.publish('session-a', makeSession('session-a')); + sessionStore.publish('session-b', makeSession('session-b')); const order: string[] = []; let active = 0; diff --git a/src/daemon/__tests__/request-router-typed-error.test.ts b/src/daemon/__tests__/request-router-typed-error.test.ts index bd54eb4d2b..6170f5cf40 100644 --- a/src/daemon/__tests__/request-router-typed-error.test.ts +++ b/src/daemon/__tests__/request-router-typed-error.test.ts @@ -100,7 +100,7 @@ test('fact-owned UNSUPPORTED_OPERATION errors do not fabricate legacy supportedO const { sessionStore, handler } = makeHandler(); // Linux refuses native perf from exact runtime facts. The retired capability matrix has no // trustworthy platform summary to graft onto that operation-level refusal. - sessionStore.set( + sessionStore.publish( 'typed-error', makeSession('typed-error', { ...TENANT_SESSION_DEFAULTS, @@ -125,7 +125,7 @@ test('fact-owned UNSUPPORTED_OPERATION errors do not fabricate legacy supportedO test('DEVICE_IN_USE errors are flagged retriable; supportedOn stays absent', async () => { const { sessionStore, handler } = makeHandler(); - sessionStore.set('typed-error', makeIosSession('typed-error')); + sessionStore.publish('typed-error', makeIosSession('typed-error')); // R56 put `app-switcher` on a bound operation, so the failure is raised where the device work // happens rather than by the retired dispatcher. systemRuntimeSpies.appSwitcher.mockRejectedValue(new AppError('DEVICE_IN_USE', 'device busy')); @@ -140,7 +140,7 @@ test('DEVICE_IN_USE errors are flagged retriable; supportedOn stays absent', asy test('deterministic errors (INVALID_ARGS) are returned with the default shape — no typed-error fields', async () => { const { sessionStore, handler } = makeHandler(); - sessionStore.set('typed-error', makeIosSession('typed-error')); + sessionStore.publish('typed-error', makeIosSession('typed-error')); // Conflicting explicit selector under a reject lock policy fails with INVALID_ARGS // before dispatch — a deterministic error. @@ -171,7 +171,7 @@ test('BLOCKER 2 (second follow-up): a repair-close platform-close failure surfac ...TENANT_SESSION_DEFAULTS, actions: [{ ts: 1, command: 'open', positionals: ['Demo'], flags: {} }], }); - sessionStore.set('typed-error', session); + sessionStore.publish('typed-error', session); // DEVICE_NOT_FOUND is not in `retriableForErrorCode`'s conservative allow // list — if the handler ever regressed to relying on that code-level @@ -216,7 +216,7 @@ test('#1391: an ordinary close-time script-save failure surfaces details.reason/ status: 'armed', target: { kind: 'explicit', path: targetPath, force: false }, }; - sessionStore.set('typed-error', session); + sessionStore.publish('typed-error', session); try { // Untargeted close: no positionals, so no platform close is dispatched diff --git a/src/daemon/__tests__/request-runtime-binding-router.test.ts b/src/daemon/__tests__/request-runtime-binding-router.test.ts index 19253287fa..ecb28d7b19 100644 --- a/src/daemon/__tests__/request-runtime-binding-router.test.ts +++ b/src/daemon/__tests__/request-runtime-binding-router.test.ts @@ -50,7 +50,7 @@ test('primary request failure survives a rejecting binding disposal', async () = function makeHandler(gateway: DeviceRuntimeGateway) { const sessionStore = makeSessionStore('request-runtime-binding-router-'); - sessionStore.set('session', { + sessionStore.publish('session', { name: 'session', device: { platform: 'android', id: 'emulator-5554', name: 'Pixel', kind: 'emulator' }, appBundleId: 'com.example.app', diff --git a/src/daemon/__tests__/request-save-script-transports.test.ts b/src/daemon/__tests__/request-save-script-transports.test.ts index 51f3f78847..1dd4273295 100644 --- a/src/daemon/__tests__/request-save-script-transports.test.ts +++ b/src/daemon/__tests__/request-save-script-transports.test.ts @@ -68,7 +68,7 @@ function setup(): Harness { roots.push(root); const sessionStore = new SessionStore(path.join(root, 'sessions')); const session = makeIosSession(SESSION); - sessionStore.set(SESSION, session); + sessionStore.publish(SESSION, session); const handleRequest = createRequestHandler({ logPath: path.join(root, 'daemon.log'), token: TOKEN, diff --git a/src/daemon/__tests__/runtime-binding-conformance.ts b/src/daemon/__tests__/runtime-binding-conformance.ts index bfc125365c..43ed1454ff 100644 --- a/src/daemon/__tests__/runtime-binding-conformance.ts +++ b/src/daemon/__tests__/runtime-binding-conformance.ts @@ -148,7 +148,7 @@ export const conformedRuntimeBindings = { resolve: async (device, bindings) => { const sessionStore = makeSessionStore('agent-device-runtime-binding-conformance-'); const session = makeSession('diff-runtime', { device, appBundleId: 'com.example.app' }); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const response = await dispatchSnapshotDiffViaRuntime({ req: { command: 'diff', diff --git a/src/daemon/__tests__/selector-capture-binding.test.ts b/src/daemon/__tests__/selector-capture-binding.test.ts index 1d94126750..d0046d80d7 100644 --- a/src/daemon/__tests__/selector-capture-binding.test.ts +++ b/src/daemon/__tests__/selector-capture-binding.test.ts @@ -134,7 +134,7 @@ test('a session without a tracked app selects the without-active-app plan', asyn test('the bound construction path admits and binds before it builds a runtime', async () => { const fixture = selectorCaptureFixture(); const sessionStore = makeSessionStore(); - sessionStore.set('bound', makeAndroidSession('bound')); + sessionStore.publish('bound', makeAndroidSession('bound')); const resolved = await createBoundSelectorRuntime( { @@ -158,7 +158,7 @@ test('the bound construction path refuses an unavailable operation without build capture: { available: false, reason: 'unsupported-platform-leaf' }, }); const sessionStore = makeSessionStore(); - sessionStore.set('bound', makeAndroidSession('bound')); + sessionStore.publish('bound', makeAndroidSession('bound')); const resolved = await createBoundSelectorRuntime( { diff --git a/src/daemon/__tests__/selector-capture-runtime.test.ts b/src/daemon/__tests__/selector-capture-runtime.test.ts index 5173d75ad5..db9dd81471 100644 --- a/src/daemon/__tests__/selector-capture-runtime.test.ts +++ b/src/daemon/__tests__/selector-capture-runtime.test.ts @@ -38,7 +38,7 @@ test('selector capture cache is keyed by scoped presentation options', async () nodes: [{ ref: 'e1', index: 0, type: 'Button', label: 'A' }], }, }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); boundCapture.mockImplementation(async (input) => ({ backend: 'xctest', producer: 'apple-runner', @@ -213,7 +213,7 @@ function proofRuntime(params: { params.sessionName, params.storedSnapshot ? { snapshot: params.storedSnapshot } : {}, ); - sessionStore.set(params.sessionName, session); + sessionStore.publish(params.sessionName, session); boundCapture.mockResolvedValue({ backend: 'xctest', producer: 'apple-runner', @@ -316,7 +316,7 @@ test('a later fact-less capture does not erase an earlier repair proof', async ( function makeCaptureRuntime(sessionName: string) { const sessionStore = makeSessionStore('agent-device-selector-capture-'); const session = makeIosSession(sessionName); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const runtime = createSelectorCaptureRuntime({ ref: sessionStore.lookup(sessionName), device: session.device, diff --git a/src/daemon/__tests__/selector-recording.test.ts b/src/daemon/__tests__/selector-recording.test.ts index 9cc5a4225a..eaa0e0ac49 100644 --- a/src/daemon/__tests__/selector-recording.test.ts +++ b/src/daemon/__tests__/selector-recording.test.ts @@ -46,7 +46,7 @@ test('a repair-armed session excludes get/is/find by default but keeps recording boundary: 0, }, }); - store.set('default', session); + store.publish('default', session); recordIfSession(store, store.lookup('default'), req('get'), {}); recordIfSession(store, store.lookup('default'), req('is'), {}); @@ -71,7 +71,7 @@ test('a repair-armed session still records get/is/find dispatched as replay plan boundary: 0, }, }); - store.set('default', session); + store.publish('default', session); recordIfSession(store, store.lookup('default'), planStepReq('get'), {}); recordIfSession(store, store.lookup('default'), planStepReq('is'), {}); @@ -90,7 +90,7 @@ test('--record forces get/is/find through even while repair-armed', () => { boundary: 0, }, }); - store.set('default', session); + store.publish('default', session); recordIfSession(store, store.lookup('default'), req('get', { record: true }), {}); recordIfSession(store, store.lookup('default'), req('is', { record: true }), {}); @@ -103,7 +103,7 @@ test('outside a repair-armed session, get/is/find/wait all record normally', () const store = makeStore(); const session = makeIosSession('default'); expect(session.scriptPublication).toBeUndefined(); - store.set('default', session); + store.publish('default', session); recordIfSession(store, store.lookup('default'), req('get'), {}); recordIfSession(store, store.lookup('default'), req('is'), {}); @@ -120,7 +120,7 @@ test('outside a repair-armed session, get/is/find/wait all record normally', () test('wait absent records positionals without target-v1 annotation', () => { const store = makeStore(); - store.set('default', makeIosSession('default')); + store.publish('default', makeIosSession('default')); recordIfSession(store, store.lookup('default'), waitAbsentReq(), { waitedMs: 0 }); diff --git a/src/daemon/__tests__/session-idle-activity.test.ts b/src/daemon/__tests__/session-idle-activity.test.ts index 02557fdbdf..cb591d4773 100644 --- a/src/daemon/__tests__/session-idle-activity.test.ts +++ b/src/daemon/__tests__/session-idle-activity.test.ts @@ -46,7 +46,7 @@ function makeRequest(overrides: Partial = {}): DaemonRequest { function storeWithSession() { const sessionStore = makeSessionStore('agent-device-idle-activity-'); - sessionStore.set('default', makeIosSession('default')); + sessionStore.publish('default', makeIosSession('default')); return sessionStore; } diff --git a/src/daemon/__tests__/session-idle-expiry-harness.ts b/src/daemon/__tests__/session-idle-expiry-harness.ts index d1e913409c..f92bd4082e 100644 --- a/src/daemon/__tests__/session-idle-expiry-harness.ts +++ b/src/daemon/__tests__/session-idle-expiry-harness.ts @@ -106,7 +106,7 @@ export function createIdleExpiryHarness(): Readonly<{ createdAt: NOW - WINDOW_MS - 1, deviceClaim: { ...CLAIM }, }); - store.set(name, session); + store.publish(name, session); return session; }, // Past its window on time alone, and holding no claim — nothing another agent waits on. @@ -130,7 +130,7 @@ export function createIdleExpiryHarness(): Readonly<{ createdAt: NOW - WINDOW_MS - 1, deviceClaim: acquired.ownership, }); - fixture.sessionStore.set(name, session); + fixture.sessionStore.publish(name, session); return { session, deviceClaim: acquired.ownership }; }, claimFileHeld: (deviceClaim) => fs.existsSync(resolveDeviceClaimPath(deviceClaim.deviceKey)), diff --git a/src/daemon/__tests__/session-routing.test.ts b/src/daemon/__tests__/session-routing.test.ts index 2de5ff75dc..d12a00ea29 100644 --- a/src/daemon/__tests__/session-routing.test.ts +++ b/src/daemon/__tests__/session-routing.test.ts @@ -36,7 +36,7 @@ function makeStore(t: TestContext): SessionStore { test('does not reuse lone active session for implicit default session from another scope', (t) => { const store = makeStore(t); - store.set('android', makeSession('android')); + store.publish('android', makeSession('android')); const cwd = mkdtempForTestSync('agent-device-cwd-scope-'); t.onTestFinished(() => { fs.rmSync(cwd, { recursive: true, force: true }); @@ -223,7 +223,7 @@ test('names an implicit session by the platform it selects', (t) => { test('gives each platform its own implicit session in one workspace', (t) => { const { cwd, scopeId } = makeWorkspaceCwd(t); const store = makeStore(t); - store.set(`cwd:${scopeId}:ios`, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); + store.publish(`cwd:${scopeId}:ios`, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); // #2580: binding Android from the same checkout used to be refused because the workspace // session was already bound to Apple. @@ -239,7 +239,7 @@ test('joins the workspace session that a named platform agrees with', (t) => { const { cwd, scopeId } = makeWorkspaceCwd(t); const store = makeStore(t); const openedWithoutPlatform = `cwd:${scopeId}:default`; - store.set(openedWithoutPlatform, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); + store.publish(openedWithoutPlatform, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); // Routing must keep the store key, so a platform-tagged command writes into the artifacts // of the session it joined instead of a second directory. @@ -251,7 +251,7 @@ test('joins the workspace session that a named platform agrees with', (t) => { test('does not join a workspace session bound to another platform', (t) => { const { cwd, scopeId } = makeWorkspaceCwd(t); const store = makeStore(t); - store.set(`cwd:${scopeId}:ios`, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); + store.publish(`cwd:${scopeId}:ios`, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); const resolved = resolveEffectiveSessionName( implicitRequest(cwd, { platform: 'android', device: 'Pixel 9' }, 'boot'), @@ -265,7 +265,7 @@ test('routes a platform-less request to the workspace sole implicit session', (t const { cwd, scopeId } = makeWorkspaceCwd(t); const store = makeStore(t); const iosSession = `cwd:${scopeId}:ios`; - store.set(iosSession, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); + store.publish(iosSession, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); const resolved = resolveEffectiveSessionName(implicitRequest(cwd, {}, 'press'), store, { attachesToSession: true, @@ -277,8 +277,8 @@ test('routes a platform-less request to the workspace sole implicit session', (t test('refuses a platform-less request when the workspace holds several implicit sessions', (t) => { const { cwd, scopeId } = makeWorkspaceCwd(t); const store = makeStore(t); - store.set(`cwd:${scopeId}:ios`, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); - store.set(`cwd:${scopeId}:android`, makeWorkspaceSession(scopeId, ANDROID_EMULATOR)); + store.publish(`cwd:${scopeId}:ios`, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); + store.publish(`cwd:${scopeId}:android`, makeWorkspaceSession(scopeId, ANDROID_EMULATOR)); assert.throws( () => @@ -303,7 +303,7 @@ test('routes a platform-less request to a sole default-leaf session', (t) => { const { cwd, scopeId } = makeWorkspaceCwd(t); const store = makeStore(t); const openedWithoutPlatform = `cwd:${scopeId}:default`; - store.set(openedWithoutPlatform, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); + store.publish(openedWithoutPlatform, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); const resolved = resolveEffectiveSessionName(implicitRequest(cwd, {}, 'press'), store, { attachesToSession: true, @@ -318,8 +318,8 @@ test('refuses a platform-less request when a default-leaf session shares the wor // The #2580 shape: iOS was opened without `--platform`, so it owns the `default` leaf, and // Android then took its own platform leaf. Preferring `default` here would run a bare `press` // on the iOS device the caller is no longer addressing. - store.set(`cwd:${scopeId}:default`, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); - store.set(`cwd:${scopeId}:android`, makeWorkspaceSession(scopeId, ANDROID_EMULATOR)); + store.publish(`cwd:${scopeId}:default`, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); + store.publish(`cwd:${scopeId}:android`, makeWorkspaceSession(scopeId, ANDROID_EMULATOR)); assert.throws( () => @@ -343,8 +343,8 @@ test('refuses a platform-less request when a default-leaf session shares the wor test('keeps inventory commands routable across implicit session ambiguity', (t) => { const { cwd, scopeId } = makeWorkspaceCwd(t); const store = makeStore(t); - store.set(`cwd:${scopeId}:ios`, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); - store.set(`cwd:${scopeId}:android`, makeWorkspaceSession(scopeId, ANDROID_EMULATOR)); + store.publish(`cwd:${scopeId}:ios`, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); + store.publish(`cwd:${scopeId}:android`, makeWorkspaceSession(scopeId, ANDROID_EMULATOR)); // `session list` is how a caller discovers the two addresses, so it must not be refused. const resolved = resolveEffectiveSessionName(implicitRequest(cwd, {}, 'session_list'), store, { @@ -357,7 +357,10 @@ test('keeps inventory commands routable across implicit session ambiguity', (t) test('keeps a named session out of implicit workspace routing', (t) => { const { cwd, scopeId } = makeWorkspaceCwd(t); const store = makeStore(t); - store.set('qa', { ...makeWorkspaceSession(scopeId, ANDROID_EMULATOR), sessionScope: undefined }); + store.publish('qa', { + ...makeWorkspaceSession(scopeId, ANDROID_EMULATOR), + sessionScope: undefined, + }); const resolved = resolveEffectiveSessionName(implicitRequest(cwd, {}, 'press'), store, { attachesToSession: true, @@ -369,7 +372,10 @@ test('keeps a named session out of implicit workspace routing', (t) => { test('ignores an implicit session owned by another workspace', (t) => { const { cwd } = makeWorkspaceCwd(t); const store = makeStore(t); - store.set('cwd:0000000000000000:ios', makeWorkspaceSession('0000000000000000', IOS_SIMULATOR)); + store.publish( + 'cwd:0000000000000000:ios', + makeWorkspaceSession('0000000000000000', IOS_SIMULATOR), + ); const resolved = resolveEffectiveSessionName(implicitRequest(cwd, {}, 'press'), store, { attachesToSession: true, @@ -410,8 +416,8 @@ test('treats an unusable platform value as no platform at all', (t) => { test('refuses a platform selector that several implicit sessions equally answer', (t) => { const { cwd, scopeId } = makeWorkspaceCwd(t); const store = makeStore(t); - store.set(`cwd:${scopeId}:ios`, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); - store.set(`cwd:${scopeId}:macos`, { + store.publish(`cwd:${scopeId}:ios`, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); + store.publish(`cwd:${scopeId}:macos`, { ...makeWorkspaceSession(scopeId, { platform: 'apple', appleOs: 'macos', @@ -441,8 +447,8 @@ test('prefers the session already keyed to the requested platform', (t) => { const { cwd, scopeId } = makeWorkspaceCwd(t); const store = makeStore(t); // Both sessions answer `--platform ios`: the one keyed to it, and one opened without a platform. - store.set(`cwd:${scopeId}:ios`, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); - store.set( + store.publish(`cwd:${scopeId}:ios`, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); + store.publish( `cwd:${scopeId}:default`, makeWorkspaceSession(scopeId, { ...IOS_SIMULATOR, id: 'SIM-002', name: 'iPhone 16 Plus' }), ); @@ -456,7 +462,7 @@ test('joins the session already keyed to the platform-less default leaf', (t) => const { cwd, scopeId } = makeWorkspaceCwd(t); const store = makeStore(t); const defaultAddress = `cwd:${scopeId}:default`; - store.set(defaultAddress, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); + store.publish(defaultAddress, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); const resolved = resolveEffectiveSessionName(implicitRequest(cwd, {}, 'press'), store, { attachesToSession: true, @@ -469,7 +475,7 @@ test('keeps a same-platform device mismatch on the bound session instead of fork const { cwd, scopeId } = makeWorkspaceCwd(t); const store = makeStore(t); const boundSession = `cwd:${scopeId}:default`; - store.set(boundSession, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); + store.publish(boundSession, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); // `--platform ios` agrees with the bound session; only the device disagrees. Routing hands the // request to that session so the shared selector rules refuse the mismatch, rather than opening @@ -486,7 +492,7 @@ test('keeps a same-platform target mismatch on the bound session', (t) => { const { cwd, scopeId } = makeWorkspaceCwd(t); const store = makeStore(t); const boundSession = `cwd:${scopeId}:default`; - store.set(boundSession, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); + store.publish(boundSession, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); const resolved = resolveEffectiveSessionName( implicitRequest(cwd, { platform: 'ios', target: 'tv' }), @@ -515,7 +521,7 @@ test('routes a session-lock platform into the implicit session key', (t) => { test('routes a session-lock platform past a workspace session bound to another platform', (t) => { const { cwd, scopeId } = makeWorkspaceCwd(t); const store = makeStore(t); - store.set(`cwd:${scopeId}:ios`, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); + store.publish(`cwd:${scopeId}:ios`, makeWorkspaceSession(scopeId, IOS_SIMULATOR)); // Candidate matching that reads only `flags` sees no platform here, so the iOS session appears // to agree and the request runs on it; the lock policy then leaves the platform unset because a @@ -536,10 +542,10 @@ test('counts one session reachable under two addresses once', (t) => { const store = makeStore(t); const address = `cwd:${scopeId}:ios`; const session = makeWorkspaceSession(scopeId, IOS_SIMULATOR); - store.set(address, session); + store.publish(address, session); // A writer that stores by `SessionState.name` publishes the same session a second time. It must // not read as a second session the caller has to disambiguate. - store.set(session.name, session); + store.publish(session.name, session); const resolved = resolveEffectiveSessionName(implicitRequest(cwd, {}, 'press'), store, { attachesToSession: true, diff --git a/src/daemon/__tests__/session-store.test.ts b/src/daemon/__tests__/session-store.test.ts index 0455a280cb..1258c6d85a 100644 --- a/src/daemon/__tests__/session-store.test.ts +++ b/src/daemon/__tests__/session-store.test.ts @@ -117,7 +117,7 @@ test('session lease metadata round-trips through the store', () => { expiresAt: 123_456, }; - store.set(session.name, session); + store.publish(session.name, session); assert.deepEqual(store.get(session.name)?.lease, session.lease); }); @@ -125,7 +125,7 @@ test('session lease metadata round-trips through the store', () => { test('sessions without lease metadata remain valid', () => { const { store, session } = makeFixture('agent-device-session-unleased-'); - store.set(session.name, session); + store.publish(session.name, session); assert.equal(store.get(session.name)?.lease, undefined); }); @@ -848,7 +848,7 @@ test('BLOCKER 3: finalizeRepairTeardown auto-commit records a terminal close, pr test('noteSessionActivity stamps the live record and ignores an unknown address', () => { const { store, session } = makeFixture('agent-device-store-note-activity-'); - store.set('default', session); + store.publish('default', session); assert.equal(session.lastActivityAtMs, undefined); store.noteSessionActivity('default', 5_000); diff --git a/src/daemon/__tests__/snapshot-command-runtime.test.ts b/src/daemon/__tests__/snapshot-command-runtime.test.ts index 80002fcb8d..2105920e7a 100644 --- a/src/daemon/__tests__/snapshot-command-runtime.test.ts +++ b/src/daemon/__tests__/snapshot-command-runtime.test.ts @@ -39,7 +39,7 @@ for (const command of ['snapshot', 'diff snapshot'] as const) { test(`${command} forwards request cancellation into snapshot dispatch`, async () => { const sessionName = 'default'; const sessionStore = makeSessionStore('agent-device-snapshot-cancellation-'); - sessionStore.set(sessionName, makeAndroidSession(sessionName)); + sessionStore.publish(sessionName, makeAndroidSession(sessionName)); const requestId = `snapshot-cancellation-${command.replace(' ', '-')}`; const registration = registerRequestAbort(requestId); if (!registration) throw new Error('expected request abort registration'); diff --git a/src/daemon/__tests__/snapshot-custom-actions-platform-guard.test.ts b/src/daemon/__tests__/snapshot-custom-actions-platform-guard.test.ts index 07e2da8d76..bb5b8e9767 100644 --- a/src/daemon/__tests__/snapshot-custom-actions-platform-guard.test.ts +++ b/src/daemon/__tests__/snapshot-custom-actions-platform-guard.test.ts @@ -44,7 +44,7 @@ function snapshotDeviceRuntimeGateway(): DeviceRuntimeGateway { const harness = await runtimeHarness({}); const sessionStore = makeSessionStore('agent-device-diff-runtime-plan-'); - sessionStore.set('diff-runtime', { + sessionStore.publish('diff-runtime', { ...harness.session, ...(activeApp ? { appBundleId: 'com.example.app' } : {}), }); @@ -181,7 +181,7 @@ test('preserves initialized, unchanged, and changed diff results through one bou captures: [baseCapture, baseCapture, changedCapture], }); const sessionStore = makeSessionStore('agent-device-diff-runtime-results-'); - sessionStore.set('diff-runtime', { ...harness.session, appBundleId: 'com.example.app' }); + sessionStore.publish('diff-runtime', { ...harness.session, appBundleId: 'com.example.app' }); const run = async () => await dispatchSnapshotDiffViaRuntime({ @@ -230,7 +230,7 @@ test('a sparse internal diff capture returns no screenshot fallback artifact', a ], }); const sessionStore = makeSessionStore('agent-device-diff-runtime-sparse-'); - sessionStore.set('diff-runtime', { ...harness.session, appBundleId: 'com.example.app' }); + sessionStore.publish('diff-runtime', { ...harness.session, appBundleId: 'com.example.app' }); const response = await dispatchSnapshotDiffViaRuntime({ req: { command: 'diff', positionals: ['snapshot'], token: 't', session: 'diff-runtime' }, diff --git a/src/daemon/__tests__/snapshot-quality-latch.test.ts b/src/daemon/__tests__/snapshot-quality-latch.test.ts index 337bf2e031..5a5ba0616b 100644 --- a/src/daemon/__tests__/snapshot-quality-latch.test.ts +++ b/src/daemon/__tests__/snapshot-quality-latch.test.ts @@ -190,7 +190,7 @@ function scenario() { const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; const session = makeIosSession(sessionName, { appBundleId: 'com.example.app' }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); return { sessionStore, sessionName, logPath: path.join(root, 'daemon.log') }; } diff --git a/src/daemon/__tests__/snapshot-runtime-binding.test.ts b/src/daemon/__tests__/snapshot-runtime-binding.test.ts index b512636855..3b87f1b483 100644 --- a/src/daemon/__tests__/snapshot-runtime-binding.test.ts +++ b/src/daemon/__tests__/snapshot-runtime-binding.test.ts @@ -29,7 +29,7 @@ test('the owning interface binds exactly the session device the facts were admit }; const sessionStore = makeSessionStore(); // An active app: the fixture admits captureSnapshot for a local iOS simulator only with one. - sessionStore.set( + sessionStore.publish( 'bind-test', makeIosSession('bind-test', { device: IOS_SIMULATOR, appBundleId: 'com.example.app' }), ); diff --git a/src/daemon/__tests__/snapshot-runtime-disclosure.test.ts b/src/daemon/__tests__/snapshot-runtime-disclosure.test.ts index 75e59093d1..4ce0805023 100644 --- a/src/daemon/__tests__/snapshot-runtime-disclosure.test.ts +++ b/src/daemon/__tests__/snapshot-runtime-disclosure.test.ts @@ -159,7 +159,7 @@ function scenario(params: { storedRepair?: boolean }) { iosSystemSurfaceBundleId: 'com.apple.SafariViewService', }; } - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); return { sessionStore, sessionName, logPath: path.join(root, 'daemon.log') }; } diff --git a/src/daemon/__tests__/sparse-fallback-screenshot.test.ts b/src/daemon/__tests__/sparse-fallback-screenshot.test.ts index a4a1a8d00a..703c779cdb 100644 --- a/src/daemon/__tests__/sparse-fallback-screenshot.test.ts +++ b/src/daemon/__tests__/sparse-fallback-screenshot.test.ts @@ -25,7 +25,10 @@ async function scenario() { const root = await mkdtempForTest('agent-device-sparse-fallback-'); const sessionStore = new SessionStore(path.join(root, 'sessions')); const sessionName = 'default'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); return { sessionStore, sessionName, logPath: path.join(root, 'daemon.log') }; } diff --git a/src/daemon/__tests__/system-button-runtime.test.ts b/src/daemon/__tests__/system-button-runtime.test.ts index f6e1b49a12..a95d1f0f7b 100644 --- a/src/daemon/__tests__/system-button-runtime.test.ts +++ b/src/daemon/__tests__/system-button-runtime.test.ts @@ -170,7 +170,7 @@ test('request router joins home admission to execution, recording, and ref inval const sessionStore = makeSessionStore('agent-device-home-generic-'); const session = makeSession('system-button-runtime', { device: iosSimulator }); activateCompleteRefFrame(session); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const handler = createRequestHandler({ logPath: '/tmp/daemon.log', token: 't', diff --git a/src/daemon/__tests__/tv-remote-runtime.test.ts b/src/daemon/__tests__/tv-remote-runtime.test.ts index 17fdb7713c..171cecb8b6 100644 --- a/src/daemon/__tests__/tv-remote-runtime.test.ts +++ b/src/daemon/__tests__/tv-remote-runtime.test.ts @@ -214,7 +214,7 @@ test('request router joins tv-remote admission to execution and ref invalidation const sessionStore = makeSessionStore('agent-device-tv-remote-generic-'); const session = makeSession('tv-remote-runtime', { device: vegaVvd }); activateCompleteRefFrame(session); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const handler = createRequestHandler({ logPath: '/tmp/daemon.log', token: 't', diff --git a/src/daemon/__tests__/viewport-runtime.test.ts b/src/daemon/__tests__/viewport-runtime.test.ts index 08235b3961..e48fc75d0f 100644 --- a/src/daemon/__tests__/viewport-runtime.test.ts +++ b/src/daemon/__tests__/viewport-runtime.test.ts @@ -165,7 +165,7 @@ test('request router joins viewport admission to execution, recording, and ref i const sessionStore = makeSessionStore('agent-device-viewport-generic-'); const session = makeSession('viewport-runtime', { device: webDevice }); activateCompleteRefFrame(session); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const handler = createRequestHandler({ logPath: '/tmp/daemon.log', token: 't', diff --git a/src/daemon/__tests__/wait-runtime.test.ts b/src/daemon/__tests__/wait-runtime.test.ts index ff76e6fa85..01225ab095 100644 --- a/src/daemon/__tests__/wait-runtime.test.ts +++ b/src/daemon/__tests__/wait-runtime.test.ts @@ -212,7 +212,7 @@ async function runWait( backend: 'web', } as unknown as NonNullable; } - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const response = await handleSnapshotCommands({ req: waitRequest(positionals, flags), sessionName: session.name, diff --git a/src/daemon/selector-runtime-backend.test.ts b/src/daemon/selector-runtime-backend.test.ts index 977f32607a..a1d9a82584 100644 --- a/src/daemon/selector-runtime-backend.test.ts +++ b/src/daemon/selector-runtime-backend.test.ts @@ -30,7 +30,7 @@ test('wait text passes its poll deadline signal to the Apple runner fast path', createdAt: Date.now(), actions: [], }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); let observedSignal: AbortSignal | undefined; // R35: every selector runtime is bound, so the poll deadline must reach the platform through // the bound capture's per-capture signal — the seam the runner used to be reached through. @@ -101,7 +101,7 @@ test('daemon wait stable pins private-ax on emitted snapshot runner requests', a actions: [], snapshot, }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const capture = vi.fn(async (_input: CaptureSnapshotInput): Promise => ({ backend: 'xctest', producer: 'apple-runner', diff --git a/src/daemon/server/daemon-idle-reap.test.ts b/src/daemon/server/daemon-idle-reap.test.ts index 2efee9bd79..92832ac32f 100644 --- a/src/daemon/server/daemon-idle-reap.test.ts +++ b/src/daemon/server/daemon-idle-reap.test.ts @@ -54,12 +54,12 @@ test('resolveDaemonIdleReapMs ignores invalid overrides', () => { test('hasReapBlockingOpenSessions reflects the session store', () => { assert.equal(hasReapBlockingOpenSessions(sessionStore), false); - sessionStore.set('default', makeSession()); + sessionStore.publish('default', makeSession()); assert.equal(hasReapBlockingOpenSessions(sessionStore), true); }); test('hasActiveRecording is true only when a stored session carries a recording', () => { - sessionStore.set('default', makeSession()); + sessionStore.publish('default', makeSession()); assert.equal(hasActiveRecording(sessionStore), false); const recordingSession = makeSession(); @@ -69,7 +69,9 @@ test('hasActiveRecording is true only when a stored session carries a recording' startedAt: Date.now(), showTouches: false, }); - sessionStore.set('default', recordingSession); + sessionStore.update(sessionStore.lookup('default')!, { + screenRecording: recordingSession.screenRecording, + }); assert.equal(hasActiveRecording(sessionStore), true); }); @@ -77,7 +79,7 @@ test('isDaemonIdle requires no in-flight requests, no sessions, and no recording assert.equal(isDaemonIdle({ sessionStore, inFlightRequestCount: 0 }), true); assert.equal(isDaemonIdle({ sessionStore, inFlightRequestCount: 1 }), false); - sessionStore.set('default', makeSession()); + sessionStore.publish('default', makeSession()); assert.equal(isDaemonIdle({ sessionStore, inFlightRequestCount: 0 }), false); }); @@ -86,7 +88,7 @@ test('isDaemonIdle requires no in-flight requests, no sessions, and no recording // reaped (with a tombstone) rather than pinning the daemon forever. --- test('a repair-armed, un-committed session does NOT block idle-reap', () => { - sessionStore.set( + sessionStore.publish( 'default', makeSession({ scriptPublication: { @@ -102,7 +104,7 @@ test('a repair-armed, un-committed session does NOT block idle-reap', () => { }); test('a repair-armed session that has already COMMITTED still blocks idle-reap (until close deletes it)', () => { - sessionStore.set( + sessionStore.publish( 'default', makeSession({ scriptPublication: { @@ -118,13 +120,13 @@ test('a repair-armed session that has already COMMITTED still blocks idle-reap ( }); test('an ordinary (non-repair) open session still blocks idle-reap', () => { - sessionStore.set('default', makeSession()); + sessionStore.publish('default', makeSession()); assert.equal(hasReapBlockingOpenSessions(sessionStore), true); assert.equal(isDaemonIdle({ sessionStore, inFlightRequestCount: 0 }), false); }); test('a normal session alongside a reapable repair session still blocks idle-reap', () => { - sessionStore.set( + sessionStore.publish( 'default', makeSession({ name: 'default', @@ -136,7 +138,7 @@ test('a normal session alongside a reapable repair session still blocks idle-rea }, }), ); - sessionStore.set('other', makeSession({ name: 'other' })); + sessionStore.publish('other', makeSession({ name: 'other' })); assert.equal(hasReapBlockingOpenSessions(sessionStore), true); assert.equal(isDaemonIdle({ sessionStore, inFlightRequestCount: 0 }), false); }); @@ -164,7 +166,7 @@ test('idle reap fires after the idle window when nothing is using the daemon', a test('idle reap does not fire while a session is open', async () => { vi.useFakeTimers(); let reaped = 0; - sessionStore.set('default', makeSession()); + sessionStore.publish('default', makeSession()); const idleReap = createDaemonIdleReap({ sessionStore, getInFlightRequestCount: () => 0, @@ -192,7 +194,7 @@ test('idle reap does not fire while a recording is active', async () => { startedAt: Date.now(), showTouches: false, }); - sessionStore.set('default', recordingSession); + sessionStore.publish('default', recordingSession); const idleReap = createDaemonIdleReap({ sessionStore, getInFlightRequestCount: () => 0, diff --git a/src/daemon/server/daemon-runtime-device-claims.test.ts b/src/daemon/server/daemon-runtime-device-claims.test.ts index 05bfb11e22..86c91fb19f 100644 --- a/src/daemon/server/daemon-runtime-device-claims.test.ts +++ b/src/daemon/server/daemon-runtime-device-claims.test.ts @@ -36,7 +36,7 @@ function setup(): { session: SessionState; sessionStore: SessionStore; stateDir: actions: [], }; const sessionStore = new SessionStore(path.join(stateDir, 'sessions')); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); return { session, sessionStore, stateDir }; } diff --git a/src/daemon/server/daemon-runtime-recording-teardown.test.ts b/src/daemon/server/daemon-runtime-recording-teardown.test.ts index f4eae37115..1bdf9c6495 100644 --- a/src/daemon/server/daemon-runtime-recording-teardown.test.ts +++ b/src/daemon/server/daemon-runtime-recording-teardown.test.ts @@ -31,7 +31,7 @@ test('daemon shutdown awaits durable recording finalization inside its extended }), ); const session = makeRecordingSession({ name: 'shutdown-recording', sessionStore, finish }); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const stderrChunks: string[] = []; const teardown = teardownDaemonSessionForShutdown({ @@ -68,7 +68,7 @@ test('daemon shutdown resolves durable recording resources through the effective }), }); session.name = 'default'; - sessionStore.set(effectiveSessionName, session); + sessionStore.publish(effectiveSessionName, session); const stderrChunks: string[] = []; await teardownDaemonSessionForShutdown({ diff --git a/src/daemon/server/daemon-runtime-web-cleanup.test.ts b/src/daemon/server/daemon-runtime-web-cleanup.test.ts index 69459e22e5..0e20e81ea1 100644 --- a/src/daemon/server/daemon-runtime-web-cleanup.test.ts +++ b/src/daemon/server/daemon-runtime-web-cleanup.test.ts @@ -32,7 +32,7 @@ test('daemon-startup web cleanup passes open web sessions to the reaper', async try { await installFakeManagedAgentBrowser(stateDir); const sessionStore = new SessionStore(path.join(stateDir, 'sessions')); - sessionStore.set('web-session', { + sessionStore.publish('web-session', { name: 'web-session', device: WEB_DESKTOP_DEVICE, createdAt: Date.now(), diff --git a/src/daemon/server/daemon-runtime-web-close-teardown.test.ts b/src/daemon/server/daemon-runtime-web-close-teardown.test.ts index 64e8d377de..1613a97b87 100644 --- a/src/daemon/server/daemon-runtime-web-close-teardown.test.ts +++ b/src/daemon/server/daemon-runtime-web-close-teardown.test.ts @@ -31,7 +31,7 @@ test('daemon shutdown awaits a slow web close inside its extended budget', async await installFakeManagedAgentBrowser(root); const sessionStore = new SessionStore(path.join(root, 'sessions')); const session = makeWebSession('shutdown-slow-web-session'); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); mockRunCmd.mockImplementation( async () => await new Promise((resolve) => { @@ -74,7 +74,7 @@ test('daemon shutdown closes an open web session immediately, without waiting fo // Teardown runs while the session it is tearing down is still in the store (session deletion // happens after), which is also what keeps agent-browser's provider-startup orphan sweep from // treating this session's own browser as an orphan and scanning real host processes for it. - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); mockRunCmd.mockResolvedValue({ stdout: JSON.stringify({ success: true, data: {} }), stderr: '', @@ -107,7 +107,7 @@ test('daemon shutdown reports a web close failure on stderr instead of losing it await installFakeManagedAgentBrowser(root); const sessionStore = new SessionStore(path.join(root, 'sessions')); const session = makeWebSession('shutdown-web-close-failure-session'); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); mockRunCmd.mockResolvedValue({ stdout: JSON.stringify({ success: false, error: 'no active browser session' }), stderr: '', 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 4d1fa1051a..2780321e94 100644 --- a/src/daemon/server/daemon-session-idle-expiry-scheduling.test.ts +++ b/src/daemon/server/daemon-session-idle-expiry-scheduling.test.ts @@ -316,14 +316,10 @@ test('a session that moved to another device is fenced by that device, not the s }); const command = withKeyedLock(locks, 'session:default', async () => { await commandHeld; - sessionStore.set( - 'default', - makeIosSession('default', { - createdAt: NOW - WINDOW_MS - 1, - device: { ...IOS_SIMULATOR, id: 'sim-2', name: 'iPhone 17' }, - deviceClaim: { ...CLAIM, deviceKey: 'ios:sim-2' }, - }), - ); + sessionStore.update(sessionStore.lookup('default')!, { + device: { ...IOS_SIMULATOR, id: 'sim-2', name: 'iPhone 17' }, + deviceClaim: { ...CLAIM, deviceKey: 'ios:sim-2' }, + }); }); // A command on the NEW session's device is in flight the whole time. @@ -454,14 +450,14 @@ test('a settle that outruns its wait still finishes the release it started', asy test('a daemon beginning to leave does not start settling the next session', async () => { const { sessionStore } = makeFixture('agent-device-idle-expiry-closing-'); - sessionStore.set( + sessionStore.publish( 'first', makeIosSession('first', { createdAt: NOW - WINDOW_MS - 1, deviceClaim: { ...CLAIM, deviceKey: 'ios:sim-1' }, }), ); - sessionStore.set( + sessionStore.publish( 'second', makeIosSession('second', { createdAt: NOW - WINDOW_MS - 1, diff --git a/src/daemon/server/daemon-session-idle-expiry.test.ts b/src/daemon/server/daemon-session-idle-expiry.test.ts index b27d64b898..350a31f022 100644 --- a/src/daemon/server/daemon-session-idle-expiry.test.ts +++ b/src/daemon/server/daemon-session-idle-expiry.test.ts @@ -234,7 +234,7 @@ test('a session closed by its owner stops the retry clock tracking it', async () await sweeps.swept(); assert.equal(settleCalls, 1); - sessionStore.delete('default'); + sessionStore.retire(sessionStore.lookup('default')!); await runUntilIdle(controller, 10); assert.equal(settleCalls, 1, 'nothing is left to retry once the session is gone'); controller.cancel(); @@ -243,7 +243,7 @@ test('a session closed by its owner stops the retry clock tracking it', async () test('a settled session of another kind is never touched by the sweep', async () => { const { sessionStore } = makeFixture('agent-device-idle-expiry-kind-'); // Past its deadline in time, but holding a remote lease: that lease owns the device. - sessionStore.set( + sessionStore.publish( 'leased', makeIosSession('leased', { createdAt: NOW - WINDOW_MS - 1, @@ -258,7 +258,7 @@ test('a settled session of another kind is never touched by the sweep', async () ); // Past its deadline too, but holding no claim: there is no device for another agent to wait on, // so there is nothing here an expiry could reclaim and nothing to release. - sessionStore.set('plain', unclaimedExpiredSession('plain')); + sessionStore.publish('plain', unclaimedExpiredSession('plain')); const settledNames: string[] = []; const controller = createSessionIdleExpiry({ @@ -292,7 +292,8 @@ test('a held-back settle leaves a repair transaction uncommitted, so a later pas createdAt: NOW - WINDOW_MS - 1, deviceClaim: { ...deviceClaim }, }); - sessionStore.set('default', session); + sessionStore.retire(sessionStore.lookup('default')!); + sessionStore.publish('default', session); const claimsDir = path.dirname(resolveDeviceClaimPath(deviceClaim.deviceKey)); const sweeps = createSweepBarrier(); @@ -353,7 +354,8 @@ test('a committed expiry finalizes the repair transaction it ends', async () => createdAt: NOW - WINDOW_MS - 1, deviceClaim: { ...deviceClaim }, }); - sessionStore.set('default', session); + sessionStore.retire(sessionStore.lookup('default')!); + sessionStore.publish('default', session); const sweeps = createSweepBarrier(); const controller = createSessionIdleExpiry({ From 1f6b7749e0f8651c1322166f0c854d33fe060d85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 3 Oct 2026 12:53:03 +0200 Subject: [PATCH 44/44] refactor: remove unrestricted session upsert and deletion --- .../handlers/__tests__/install-source.test.ts | 16 +++---- .../handlers/__tests__/react-native.test.ts | 28 ++++++------ .../record-runtime-stop-recovery.test.ts | 18 ++++---- .../__tests__/record-runtime.fixtures.ts | 2 +- .../__tests__/session-appstate-input.test.ts | 14 +++--- .../__tests__/session-boot-shutdown.test.ts | 4 +- .../__tests__/session-clipboard.test.ts | 2 +- .../__tests__/session-device-claims.test.ts | 4 +- .../session-doctor-app-runtime.test.ts | 4 +- .../__tests__/session-doctor-options.test.ts | 6 +-- .../__tests__/session-doctor-warmup.test.ts | 2 +- .../session-install-source-ref-frame.test.ts | 6 +-- .../__tests__/session-prepare.test.ts | 2 +- .../__tests__/session-push-ref-frame.test.ts | 4 +- .../handlers/__tests__/session-push.test.ts | 10 ++--- .../__tests__/session-reinstall.test.ts | 10 ++--- .../__tests__/session-relaunch-close.test.ts | 44 ++++++++++++------- .../__tests__/session-relaunch-guards.test.ts | 6 +-- .../__tests__/session-runtime-command.test.ts | 8 ++-- .../session-script-publication.test.ts | 21 ++++----- .../session-selector-dispatch.test.ts | 7 +-- .../handlers/__tests__/snapshot-alert.test.ts | 6 +-- .../snapshot-handler-freshness.test.ts | 18 ++++---- .../__tests__/snapshot-handler-wait.test.ts | 6 +-- .../__tests__/snapshot-handler.test.ts | 18 ++++---- .../__tests__/snapshot-scoped-refs.test.ts | 6 +-- .../snapshot-settings-handler.test.ts | 22 +++++----- .../handlers/__tests__/trace-runtime.test.ts | 2 +- .../__tests__/wait-landmark-recording.test.ts | 2 +- .../__tests__/find-production-route.test.ts | 3 +- .../internal/__tests__/find-read-only.test.ts | 2 +- .../__tests__/find-single-bind.test.ts | 2 +- .../find-target-activation-disclosure.test.ts | 2 +- .../__tests__/find-touch-runtime-fixture.ts | 2 +- .../interaction-capture-disclosure.test.ts | 6 +-- .../__tests__/interaction-common.test.ts | 8 ++-- .../interaction-dispatch-disclosure.test.ts | 8 ++-- .../interaction-gesture-drag.test.ts | 4 +- .../__tests__/interaction-get.test.ts | 44 ++++++++++++------- .../interaction-ios-tap-outcome.test.ts | 38 ++++++++-------- ...nteraction-settle-private-ax-route.test.ts | 2 +- .../__tests__/interaction-settle.test.ts | 3 +- .../interaction-target-evidence.test.ts | 20 ++++----- ...nteraction-touch-android-freshness.test.ts | 8 ++-- ...nteraction-touch-android-readiness.test.ts | 8 ++-- ...ction-touch-direct-ios-eligibility.test.ts | 17 +++++-- .../interaction-touch-direct-ios.test.ts | 9 ++-- .../__tests__/interaction-touch-fill.test.ts | 12 ++--- .../interaction-touch-payload.test.ts | 14 +++--- .../interaction-touch-press-admission.test.ts | 27 ++++++------ .../__tests__/interaction-touch-press.test.ts | 21 +++++---- .../interaction-touch-response.test.ts | 12 ++--- .../interaction-touch-runtime.test.ts | 10 ++--- .../__tests__/interaction-touch.test.ts | 16 +++---- ...interaction-type-android-readiness.test.ts | 2 +- ...on-capabilities-install-projection.test.ts | 2 +- .../__tests__/session-capabilities.test.ts | 16 +++---- ...session-close-cwd-scoped-resources.test.ts | 16 ++++--- .../session-close-error-precedence.test.ts | 10 ++--- .../session-close-lifecycle-runtime.test.ts | 14 +++--- .../session-close-resource-cleanup.test.ts | 8 ++-- .../session-close-save-script.test.ts | 4 +- .../session-close-shutdown-platform.test.ts | 14 +++--- .../session-devices-batch-runtime.test.ts | 2 +- .../session-inventory-apps-runtime.test.ts | 2 +- .../session-inventory-harmonyos.test.ts | 2 +- .../session-inventory-scoped-paths.test.ts | 18 ++++---- .../session-open-execution-runtime.test.ts | 2 +- .../__tests__/session-open-existing.test.ts | 18 ++++---- .../session-open-url-prewarm.test.ts | 18 ++++---- .../session-teardown-resources.test.ts | 16 +++---- .../internal/__tests__/session-audio.test.ts | 20 ++++----- .../internal/__tests__/session-logs.test.ts | 8 ++-- .../session-network-runtime-parity.test.ts | 8 ++-- .../__tests__/session-network.test.ts | 2 +- .../__tests__/session-observability.test.ts | 5 ++- .../__tests__/session-perf-runtime.test.ts | 5 ++- src/daemon/session-store.ts | 28 +----------- .../nightly/concurrency-torture/bindings.ts | 2 + .../nightly/concurrency-torture/harness.ts | 8 ++-- .../android-tv-remote.test.ts | 2 +- .../apple-app-log-runtime-provider.test.ts | 2 +- .../integration/provider-scenarios/harness.ts | 4 +- .../provider-scenarios/ios-fold.test.ts | 4 +- .../ios-wait-selector.test.ts | 2 +- .../network-runtime-provider.test.ts | 2 +- ...ale-provider-runtime-admission.fixtures.ts | 2 +- .../provider-scenarios/vega-tv-remote.test.ts | 2 +- 88 files changed, 445 insertions(+), 421 deletions(-) diff --git a/src/daemon/handlers/__tests__/install-source.test.ts b/src/daemon/handlers/__tests__/install-source.test.ts index ca21dfbcc4..e1243b8412 100644 --- a/src/daemon/handlers/__tests__/install-source.test.ts +++ b/src/daemon/handlers/__tests__/install-source.test.ts @@ -75,7 +75,7 @@ test('install_source materializes and deploys through one admitted runtime bindi kind: 'emulator', booted: true, }); - store.set(session.name, session); + store.publish(session.name, session); const materialize = vi.fn(async (): Promise => ({ installablePath: '/tmp/materialized/app.apk', cleanup: async () => {}, @@ -123,7 +123,7 @@ test('install_source rejects unavailable facts before binding or materializing', kind: 'emulator', booted: true, }); - store.set(session.name, session); + store.publish(session.name, session); const materialize = vi.fn(async (): Promise => ({ installablePath: '/tmp/materialized/app.apk', cleanup: async () => {}, @@ -159,7 +159,7 @@ test('install_source fails closed when a provider owner does not expose readines kind: 'emulator', booted: true, }); - store.set(session.name, session); + store.publish(session.name, session); const localMaterialization = vi.fn(async (): Promise => ({ installablePath: '/tmp/local-fallback.apk', cleanup: async () => {}, @@ -215,7 +215,7 @@ test('install_source cleans materialized paths when deployment fails after admis kind: 'emulator', booted: true, }); - store.set(session.name, session); + store.publish(session.name, session); const cleanup = vi.fn(async () => {}); const runtime = createSourceRuntime( session.device, @@ -249,7 +249,7 @@ test('install_source preserves the Android identity failure when its runtime can kind: 'emulator', booted: true, }); - store.set(session.name, session); + store.publish(session.name, session); const runtime = createSourceRuntime( session.device, async () => ({ installablePath: '/tmp/materialized/app.apk', cleanup: async () => {} }), @@ -279,7 +279,7 @@ test('install_source returns the typed iOS artifact identity supplied by its run kind: 'simulator', booted: true, }); - store.set(session.name, session); + store.publish(session.name, session); const runtime = createSourceRuntime( session.device, async () => ({ @@ -325,7 +325,7 @@ test('install_source accepts the public leaf selector of an Apple session it is kind: 'simulator', booted: true, }); - store.set(session.name, session); + store.publish(session.name, session); const runtime = createSourceRuntime( session.device, async () => ({ @@ -358,7 +358,7 @@ test('install_source still refuses a leaf selector that names a different platfo kind: 'simulator', booted: true, }); - store.set(session.name, session); + store.publish(session.name, session); const runtime = createSourceRuntime( session.device, async () => ({ installablePath: '/tmp/App.app', cleanup: async () => {} }), diff --git a/src/daemon/handlers/__tests__/react-native.test.ts b/src/daemon/handlers/__tests__/react-native.test.ts index 68dbd0853f..7d5b49252a 100644 --- a/src/daemon/handlers/__tests__/react-native.test.ts +++ b/src/daemon/handlers/__tests__/react-native.test.ts @@ -32,7 +32,7 @@ beforeEach(() => { test('react-native dismiss-overlay taps collapsed warning close affordance instead of banner center', async () => { const sessionName = 'rn-session'; const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); mockCaptureSnapshot .mockResolvedValueOnce({ snapshot: { @@ -97,7 +97,7 @@ test('react-native dismiss-overlay taps collapsed warning close affordance inste test('react-native dismiss-overlay prefers non-trailing collapsed warning close controls', async () => { const sessionName = 'rn-session'; const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); mockCaptureSnapshot.mockResolvedValue({ snapshot: { nodes: [ @@ -151,7 +151,7 @@ test('react-native dismiss-overlay prefers non-trailing collapsed warning close test('react-native dismiss-overlay does not confuse app dismiss buttons with overlay controls', async () => { const sessionName = 'rn-collapsed-with-app-dismiss-session'; const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); mockCaptureSnapshot .mockResolvedValueOnce({ snapshot: { @@ -222,7 +222,7 @@ test('react-native dismiss-overlay does not confuse app dismiss buttons with ove test('react-native dismiss-overlay rejects unsafe collapsed warning coordinate fallback', async () => { const sessionName = 'rn-session'; const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); mockCaptureSnapshot.mockResolvedValue({ snapshot: { nodes: [ @@ -266,7 +266,7 @@ test('react-native dismiss-overlay rejects unsafe collapsed warning coordinate f test('react-native dismiss-overlay dismisses RedBox error overlays instead of minimizing them', async () => { const sessionName = 'rn-redbox-session'; const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); mockCaptureSnapshot .mockResolvedValueOnce({ snapshot: { @@ -336,7 +336,7 @@ test('react-native dismiss-overlay dismisses RedBox error overlays instead of mi test('react-native dismiss-overlay reports unverified dismiss when RedBox controls remain', async () => { const sessionName = 'rn-redbox-still-full-session'; const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); const fullRedBoxSnapshot = { snapshot: { nodes: [ @@ -397,7 +397,7 @@ test('react-native dismiss-overlay reports unverified dismiss when RedBox contro test('react-native dismiss-overlay uses Dismiss when RedBox Minimize is absent', async () => { const sessionName = 'rn-redbox-dismiss-session'; const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); mockCaptureSnapshot.mockResolvedValue({ snapshot: { nodes: [ @@ -448,7 +448,7 @@ test('react-native dismiss-overlay uses Dismiss when RedBox Minimize is absent', test('react-native dismiss-overlay accepts RedBox control labels with keyboard shortcut suffixes', async () => { const sessionName = 'rn-redbox-shortcut-session'; const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); mockCaptureSnapshot.mockResolvedValue({ snapshot: { nodes: [ @@ -499,7 +499,7 @@ test('react-native dismiss-overlay accepts RedBox control labels with keyboard s test('react-native dismiss-overlay prefers concrete RedBox buttons over labeled wrappers', async () => { const sessionName = 'rn-redbox-wrapper-session'; const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); mockCaptureSnapshot.mockResolvedValue({ snapshot: { nodes: [ @@ -556,7 +556,7 @@ test('react-native dismiss-overlay prefers concrete RedBox buttons over labeled test('react-native dismiss-overlay reports verified success after a clean post-dismiss snapshot', async () => { const sessionName = 'rn-verify-session'; const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, makeSession(sessionName, 'android')); + sessionStore.publish(sessionName, makeSession(sessionName, 'android')); mockCaptureSnapshot .mockResolvedValueOnce({ snapshot: { @@ -635,7 +635,7 @@ test('react-native dismiss-overlay reports sparse verdict instead of no overlay createdAt: Date.now(), }; const previousSnapshot = session.snapshot; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockCaptureSnapshot.mockResolvedValue({ snapshot: { nodes: [ @@ -687,7 +687,7 @@ test('react-native dismiss-overlay reports sparse verdict instead of no overlay test('react-native dismiss-overlay reports unverified dismiss when post-dismiss snapshot is sparse', async () => { const sessionName = 'rn-verify-sparse-session'; const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); mockCaptureSnapshot .mockResolvedValueOnce({ snapshot: { @@ -755,7 +755,7 @@ test('react-native dismiss-overlay reports unverified dismiss when post-dismiss test('react-native dismiss-overlay reports still-visible overlays with recovery guidance', async () => { const sessionName = 'rn-verify-still-visible-session'; const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, makeSession(sessionName, 'android')); + sessionStore.publish(sessionName, makeSession(sessionName, 'android')); const overlaySnapshot = { snapshot: { nodes: [ @@ -805,7 +805,7 @@ test('react-native dismiss-overlay reports still-visible overlays with recovery test('react-native dismiss-overlay ignores app copy that only mentions RN overlay terms', async () => { const sessionName = 'rn-copy-session'; const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); mockCaptureSnapshot.mockResolvedValue({ snapshot: { nodes: [ diff --git a/src/daemon/handlers/__tests__/record-runtime-stop-recovery.test.ts b/src/daemon/handlers/__tests__/record-runtime-stop-recovery.test.ts index 539546bd48..96ad8c41a3 100644 --- a/src/daemon/handlers/__tests__/record-runtime-stop-recovery.test.ts +++ b/src/daemon/handlers/__tests__/record-runtime-stop-recovery.test.ts @@ -65,9 +65,9 @@ test('record stop after daemon-state loss reattaches only through the persisted 'capture.mp4', ); await harness.run(['start', outPath]); - const adopted = harness.sessionStore.get(harness.sessionName); - if (!adopted) throw new Error('Expected adopted recording session'); - harness.sessionStore.set(harness.sessionName, { ...adopted, screenRecording: undefined }); + const ref = harness.sessionStore.lookup(harness.sessionName); + if (!ref) throw new Error('Expected adopted recording session'); + harness.sessionStore.update(ref, { screenRecording: undefined }); const stopped = await harness.run(['stop']); @@ -87,9 +87,9 @@ test('record stop terminalizes cleanup-only exact recovery without starting a re 'capture.mp4', ); await harness.run(['start', outPath]); - const adopted = harness.sessionStore.get(harness.sessionName); - if (!adopted) throw new Error('Expected adopted recording session'); - harness.sessionStore.set(harness.sessionName, { ...adopted, screenRecording: undefined }); + const ref = harness.sessionStore.lookup(harness.sessionName); + if (!ref) throw new Error('Expected adopted recording session'); + harness.sessionStore.update(ref, { screenRecording: undefined }); const stopped = await harness.run(['stop']); @@ -120,9 +120,9 @@ test('record stop rejects a cross-session recovery manifest before exact-owner b ...record.envelope, sessionId: 'recording-b', }); - const adopted = harness.sessionStore.get(harness.sessionName); - if (!adopted) throw new Error('Expected adopted recording session'); - harness.sessionStore.set(harness.sessionName, { ...adopted, screenRecording: undefined }); + const ref = harness.sessionStore.lookup(harness.sessionName); + if (!ref) throw new Error('Expected adopted recording session'); + harness.sessionStore.update(ref, { screenRecording: undefined }); const stopped = await harness.run(['stop']); diff --git a/src/daemon/handlers/__tests__/record-runtime.fixtures.ts b/src/daemon/handlers/__tests__/record-runtime.fixtures.ts index ebab2a2433..d7afd8e640 100644 --- a/src/daemon/handlers/__tests__/record-runtime.fixtures.ts +++ b/src/daemon/handlers/__tests__/record-runtime.fixtures.ts @@ -53,7 +53,7 @@ export function makeRecordRuntimeHarness( createdAt: 1, actions: [], }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const runtime = makeRuntime(session, options.runtime); const common = { sessionName, diff --git a/src/daemon/handlers/__tests__/session-appstate-input.test.ts b/src/daemon/handlers/__tests__/session-appstate-input.test.ts index bf1a1327e6..308f84dcc8 100644 --- a/src/daemon/handlers/__tests__/session-appstate-input.test.ts +++ b/src/daemon/handlers/__tests__/session-appstate-input.test.ts @@ -32,7 +32,7 @@ import type { BindDeviceRuntime } from '../../request-runtime-binding.ts'; test('appstate on iOS requires active session on selected device', async () => { const sessionStore = makeSessionStore(); const sessionName = 'default'; - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, { platform: 'apple', id: 'sim-1', @@ -78,7 +78,7 @@ test('appstate on iOS requires active session on selected device', async () => { test('appstate returns session appName when bundle id is unavailable', async () => { const sessionStore = makeSessionStore(); const sessionName = 'sim'; - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, { platform: 'apple', id: 'sim-1', @@ -128,7 +128,7 @@ test('appstate returns session appName when bundle id is unavailable', async () test('appstate fails when iOS session has no tracked app', async () => { const sessionStore = makeSessionStore(); const sessionName = 'sim'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { platform: 'apple', @@ -256,7 +256,7 @@ test('clipboard requires an active session or explicit device selector', async ( test('clipboard rejects unsupported iOS physical devices', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-device-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { platform: 'apple', @@ -342,7 +342,7 @@ function iosSessionRequest(sessionName: string): DaemonRequest { test('appstate on iOS reads the session app state from the runner when its owner admits it', async () => { const sessionStore = makeSessionStore(); const sessionName = 'sim'; - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, IOS_SESSION_DEVICE), appBundleId: 'dev.e2e.benchmark', appName: 'Benchmark', @@ -377,7 +377,7 @@ test('appstate on iOS reads the session app state from the runner when its owner test('appstate on iOS keeps the session answer, with no state, when no runner is live to ask', async () => { const sessionStore = makeSessionStore(); const sessionName = 'sim'; - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, IOS_SESSION_DEVICE), appBundleId: 'dev.e2e.benchmark', appName: 'Benchmark', @@ -404,7 +404,7 @@ test('appstate on iOS keeps the session answer, with no state, when no runner is test('appstate on iOS keeps the session answer, with no state, when the runner cannot read one', async () => { const sessionStore = makeSessionStore(); const sessionName = 'sim'; - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, IOS_SESSION_DEVICE), appBundleId: 'dev.e2e.benchmark', appName: 'Benchmark', diff --git a/src/daemon/handlers/__tests__/session-boot-shutdown.test.ts b/src/daemon/handlers/__tests__/session-boot-shutdown.test.ts index a325dedd7c..a9b87ae488 100644 --- a/src/daemon/handlers/__tests__/session-boot-shutdown.test.ts +++ b/src/daemon/handlers/__tests__/session-boot-shutdown.test.ts @@ -44,7 +44,7 @@ test('boot requires session or explicit selector', async () => { test('boot prefers explicit device selector over active session device', async () => { const sessionStore = makeSessionStore(); const sessionName = 'default'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { platform: 'android', @@ -473,7 +473,7 @@ test('shutdown rejects active session device and points to close --shutdown', as target: 'mobile', booted: true, }; - sessionStore.set(sessionName, makeSession(sessionName, selectedDevice)); + sessionStore.publish(sessionName, makeSession(sessionName, selectedDevice)); mockResolveTargetDevice.mockResolvedValue(selectedDevice); const response = await handleSessionCommands({ diff --git a/src/daemon/handlers/__tests__/session-clipboard.test.ts b/src/daemon/handlers/__tests__/session-clipboard.test.ts index 8b99e82bb4..b2aadb1449 100644 --- a/src/daemon/handlers/__tests__/session-clipboard.test.ts +++ b/src/daemon/handlers/__tests__/session-clipboard.test.ts @@ -73,7 +73,7 @@ function harness( function request(positionals: string[]) { const sessionName = 'clipboard-session'; const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, makeSession(sessionName, androidDevice)); + sessionStore.publish(sessionName, makeSession(sessionName, androidDevice)); mockResolveTargetDevice.mockResolvedValue(androidDevice); return { sessionName, diff --git a/src/daemon/handlers/__tests__/session-device-claims.test.ts b/src/daemon/handlers/__tests__/session-device-claims.test.ts index bb60b6ec2f..8ba2fa6bb8 100644 --- a/src/daemon/handlers/__tests__/session-device-claims.test.ts +++ b/src/daemon/handlers/__tests__/session-device-claims.test.ts @@ -449,7 +449,7 @@ test('local close clears its matching device claim after teardown', async () => }); assert.equal(acquired.status, 'acquired'); if (acquired.status !== 'acquired') return; - store.set('close-claim', { + store.publish('close-claim', { name: 'close-claim', device: android, deviceClaim: acquired.ownership, @@ -494,7 +494,7 @@ test('#1391: a close-time script save failure still clears the device claim and target: { kind: 'explicit', path: targetPath, force: false }, }, }); - store.set('close-save-script-failure', session); + store.publish('close-save-script-failure', session); mockDispatch.mockResolvedValue(undefined); // Like the platform-close-error tests above, a failed close-time save is diff --git a/src/daemon/handlers/__tests__/session-doctor-app-runtime.test.ts b/src/daemon/handlers/__tests__/session-doctor-app-runtime.test.ts index 5cd51d1ad4..84394ce3d2 100644 --- a/src/daemon/handlers/__tests__/session-doctor-app-runtime.test.ts +++ b/src/daemon/handlers/__tests__/session-doctor-app-runtime.test.ts @@ -31,7 +31,7 @@ vi.mock('../session-doctor-metro.ts', () => ({ test('doctor app checks retain runtime admission failures as a failed target-app check', async () => { const sessionName = 'doctor-app-runtime-failure'; const sessionStore = makeSessionStore('agent-device-doctor-app-runtime-'); - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { name: sessionName, device: LINUX_DEVICE, createdAt: Date.now(), @@ -135,7 +135,7 @@ test('doctor preserves the legacy unsupported HarmonyOS target-app check', async }; const sessionName = 'doctor-harmony-app-runtime'; const sessionStore = makeSessionStore('agent-device-doctor-harmony-'); - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { name: sessionName, device: harmonyDevice, createdAt: Date.now(), diff --git a/src/daemon/handlers/__tests__/session-doctor-options.test.ts b/src/daemon/handlers/__tests__/session-doctor-options.test.ts index 683584b204..bb575d87a2 100644 --- a/src/daemon/handlers/__tests__/session-doctor-options.test.ts +++ b/src/daemon/handlers/__tests__/session-doctor-options.test.ts @@ -11,8 +11,8 @@ test('the same-device warning names the other session by its address, not its na const store = makeSessionStore(); const here = 'cwd:aaaaaaaaaaaaaaaa:default'; const other = 'cwd:bbbbbbbbbbbbbbbb:default'; - store.set(here, makeIosSession('default')); - store.set(other, makeIosSession('default')); + store.publish(here, makeIosSession('default')); + store.publish(other, makeIosSession('default')); const [check] = sessionChecks(store, here, store.get(here)); @@ -26,7 +26,7 @@ test('the same-device warning names the other session by its address, not its na test('a session alone on its device passes with its own address in evidence', () => { const store = makeSessionStore(); const here = 'cwd:aaaaaaaaaaaaaaaa:default'; - store.set(here, makeIosSession('default')); + store.publish(here, makeIosSession('default')); const [check] = sessionChecks(store, here, store.get(here)); diff --git a/src/daemon/handlers/__tests__/session-doctor-warmup.test.ts b/src/daemon/handlers/__tests__/session-doctor-warmup.test.ts index 2adb0e5820..dad880c522 100644 --- a/src/daemon/handlers/__tests__/session-doctor-warmup.test.ts +++ b/src/daemon/handlers/__tests__/session-doctor-warmup.test.ts @@ -62,7 +62,7 @@ beforeEach(() => { async function runDoctorWithSessionDevice(device: DeviceInfo): Promise { const sessionStore = makeSessionStore('agent-device-doctor-warmup-'); - sessionStore.set('doctor-session', { + sessionStore.publish('doctor-session', { name: 'doctor-session', createdAt: Date.now(), device, diff --git a/src/daemon/handlers/__tests__/session-install-source-ref-frame.test.ts b/src/daemon/handlers/__tests__/session-install-source-ref-frame.test.ts index 81a4a9cd2d..fc0fcfc6ea 100644 --- a/src/daemon/handlers/__tests__/session-install-source-ref-frame.test.ts +++ b/src/daemon/handlers/__tests__/session-install-source-ref-frame.test.ts @@ -19,7 +19,7 @@ const invoke = async (): Promise => { test('install_source admission failure preserves an active ref frame', async () => { const sessionStore = makeSessionStore('agent-device-session-install-source-ref-admission-'); const session = activeSession(); - sessionStore.set('default', session); + sessionStore.publish('default', session); const inspectFacts = vi.fn(async (device) => { const facts = await mockInspectDeviceRuntimeFacts(device); return { @@ -46,7 +46,7 @@ test('install_source admission failure preserves an active ref frame', async () test('install_source materialization failure preserves an active ref frame', async () => { const sessionStore = makeSessionStore('agent-device-session-install-source-ref-materialization-'); const session = activeSession(); - sessionStore.set('default', session); + sessionStore.publish('default', session); mockMaterializeAppSourceRuntime.mockRejectedValueOnce(new Error('artifact download failed')); const response = await dispatchInstallSource({ sessionStore }); @@ -63,7 +63,7 @@ test('install_source materialization failure preserves an active ref frame', asy test('install_source deploy attempt expires an active ref frame when the bound operation fails', async () => { const sessionStore = makeSessionStore('agent-device-session-install-source-ref-deploy-'); const session = activeSession(); - sessionStore.set('default', session); + sessionStore.publish('default', session); mockDeployMaterializedAppRuntime.mockImplementationOnce(async () => { expect(refFrameState(session)).toBe('expired'); expect(session.snapshotScopeSource).toBeUndefined(); diff --git a/src/daemon/handlers/__tests__/session-prepare.test.ts b/src/daemon/handlers/__tests__/session-prepare.test.ts index 5804f8e8b5..2906f6442f 100644 --- a/src/daemon/handlers/__tests__/session-prepare.test.ts +++ b/src/daemon/handlers/__tests__/session-prepare.test.ts @@ -52,7 +52,7 @@ async function runPrepare(flags: { timeoutMs?: number }): Promise<{ }> { const sessionName = 'prepare-envelope-margin'; const sessionStore = makeSessionStore('agent-device-prepare-handler-'); - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { name: sessionName, device: IOS_SIMULATOR, createdAt: Date.now(), diff --git a/src/daemon/handlers/__tests__/session-push-ref-frame.test.ts b/src/daemon/handlers/__tests__/session-push-ref-frame.test.ts index f8576d1661..97409a3a41 100644 --- a/src/daemon/handlers/__tests__/session-push-ref-frame.test.ts +++ b/src/daemon/handlers/__tests__/session-push-ref-frame.test.ts @@ -18,7 +18,7 @@ const invoke = async (): Promise => { test('push admission failure preserves an active ref frame', async () => { const sessionStore = makeSessionStore('agent-device-session-push-ref-admission-'); const session = activeSession(); - sessionStore.set('default', session); + sessionStore.publish('default', session); const inspectFacts = vi.fn(async (device) => { const facts = await mockInspectDeviceRuntimeFacts(device); return { @@ -45,7 +45,7 @@ test('push admission failure preserves an active ref frame', async () => { test('push dispatch attempt expires an active ref frame when the bound operation fails', async () => { const sessionStore = makeSessionStore('agent-device-session-push-ref-dispatch-'); const session = activeSession(); - sessionStore.set('default', session); + sessionStore.publish('default', session); mockPushNotificationRuntime.mockRejectedValueOnce(new Error('provider dispatch failed')); await expect(dispatchPush({ sessionStore })).rejects.toThrow('provider dispatch failed'); diff --git a/src/daemon/handlers/__tests__/session-push.test.ts b/src/daemon/handlers/__tests__/session-push.test.ts index 39ef7e957f..12649806cc 100644 --- a/src/daemon/handlers/__tests__/session-push.test.ts +++ b/src/daemon/handlers/__tests__/session-push.test.ts @@ -49,7 +49,7 @@ test('push requires active session or explicit device selector', async () => { test('push validates payload before runtime facts admission', async () => { const sessionStore = makeSessionStore('agent-device-session-push-invalid-'); - sessionStore.set('default', { + sessionStore.publish('default', { name: 'default', createdAt: Date.now(), actions: [], @@ -83,7 +83,7 @@ test('push validates payload before runtime facts admission', async () => { test('push runs readiness and notification through one admitted runtime binding', async () => { const sessionStore = makeSessionStore('agent-device-session-push-runtime-'); - sessionStore.set('default', { + sessionStore.publish('default', { name: 'default', createdAt: Date.now(), actions: [], @@ -141,7 +141,7 @@ test('push runs readiness and notification through one admitted runtime binding' test('push treats an existing brace-prefixed payload as a file before inline JSON parsing', async () => { const sessionStore = makeSessionStore('agent-device-session-push-payload-file-'); - sessionStore.set('default', { + sessionStore.publish('default', { name: 'default', createdAt: Date.now(), actions: [], @@ -192,7 +192,7 @@ test('push treats an existing brace-prefixed payload as a file before inline JSO test('push stops at unavailable facts without binding', async () => { const sessionStore = makeSessionStore('agent-device-session-push-unsupported-'); - sessionStore.set('default', { + sessionStore.publish('default', { name: 'default', createdAt: Date.now(), actions: [], @@ -229,7 +229,7 @@ test('push stops at unavailable facts without binding', async () => { test('push fails closed before binding when a provider owner lacks readiness', async () => { const sessionStore = makeSessionStore('agent-device-session-push-provider-readiness-'); - sessionStore.set('default', { + sessionStore.publish('default', { name: 'default', createdAt: Date.now(), actions: [], diff --git a/src/daemon/handlers/__tests__/session-reinstall.test.ts b/src/daemon/handlers/__tests__/session-reinstall.test.ts index 56a7dbca85..c255a49ad0 100644 --- a/src/daemon/handlers/__tests__/session-reinstall.test.ts +++ b/src/daemon/handlers/__tests__/session-reinstall.test.ts @@ -70,7 +70,7 @@ test('reinstall requires active session or explicit device selector', async () = test('reinstall validates required args before runtime admission', async () => { const store = makeStore(); - store.set( + store.publish( 'default', makeSession('default', { platform: 'apple', @@ -108,7 +108,7 @@ test('install binds the deployment use exactly once after one facts admission', kind: 'emulator', booted: true, }); - store.set(session.name, session); + store.publish(session.name, session); const root = mkdtempForTestSync('agent-device-install-runtime-'); const appPath = path.join(root, 'Sample.apk'); fs.writeFileSync(appPath, 'placeholder'); @@ -157,7 +157,7 @@ test('install fails closed before binding when its provider owner lacks deployme kind: 'device', booted: true, }); - store.set(session.name, session); + store.publish(session.name, session); const root = mkdtempForTestSync('agent-device-install-provider-unavailable-'); const appPath = path.join(root, 'Sample.apk'); fs.writeFileSync(appPath, 'placeholder'); @@ -216,7 +216,7 @@ test('reinstall cleans an uploaded artifact after the request-scoped operation', kind: 'simulator', booted: true, }); - store.set(session.name, session); + store.publish(session.name, session); const root = mkdtempForTestSync('agent-device-uploaded-artifact-'); const appPath = path.join(root, 'Sample.app'); fs.writeFileSync(appPath, 'placeholder'); @@ -252,7 +252,7 @@ test('HarmonyOS reinstall updates the active session identity from the runtime r kind: 'emulator', booted: true, }); - store.set(session.name, session); + store.publish(session.name, session); const root = mkdtempForTestSync('agent-device-harmony-reinstall-'); const appPath = path.join(root, 'Sample.hap'); fs.writeFileSync(appPath, 'placeholder'); diff --git a/src/daemon/handlers/__tests__/session-relaunch-close.test.ts b/src/daemon/handlers/__tests__/session-relaunch-close.test.ts index 0ee75ae19a..9af06a3e6f 100644 --- a/src/daemon/handlers/__tests__/session-relaunch-close.test.ts +++ b/src/daemon/handlers/__tests__/session-relaunch-close.test.ts @@ -157,7 +157,7 @@ function sessionRequest( test('open --relaunch closes and reopens active session app', async () => { const sessionStore = makeSessionStore('agent-device-relaunch-close-'); const sessionName = 'android-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { @@ -191,7 +191,7 @@ test('open --relaunch closes and reopens active session app', async () => { test('open --relaunch leaves the old frame expired when the close dispatch fails after dispatch (ADR 0014)', async () => { const sessionStore = makeSessionStore('agent-device-relaunch-close-'); const sessionName = 'android-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { @@ -283,7 +283,10 @@ test('open --relaunch on physical iOS retains runner through close/open', async kind: 'device' as const, booted: true, }; - sessionStore.set(sessionName, makeSession(sessionName, { device, appName: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeSession(sessionName, { device, appName: 'com.example.app' }), + ); const calls: string[] = []; mockResolveTargetDevice.mockResolvedValue(device); @@ -317,7 +320,10 @@ test('open --relaunch on iOS simulator collapses into one terminate-running open kind: 'simulator' as const, booted: true, }; - sessionStore.set(sessionName, makeSession(sessionName, { device, appName: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeSession(sessionName, { device, appName: 'com.example.app' }), + ); const calls: string[] = []; mockResolveTargetDevice.mockResolvedValue(device); @@ -354,7 +360,10 @@ test('open --relaunch on iOS simulator delegates termination to the kind: 'simulator' as const, booted: true, }; - sessionStore.set(sessionName, makeSession(sessionName, { device, appName: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeSession(sessionName, { device, appName: 'com.example.app' }), + ); const calls: string[] = []; mockResolveTargetDevice.mockResolvedValue(device); @@ -387,7 +396,10 @@ test('open --relaunch --clear-app-state on iOS simulator keeps close-first order kind: 'simulator' as const, booted: true, }; - sessionStore.set(sessionName, makeSession(sessionName, { device, appName: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeSession(sessionName, { device, appName: 'com.example.app' }), + ); const calls: string[] = []; mockResolveTargetDevice.mockResolvedValue(device); @@ -420,7 +432,7 @@ test('open --relaunch includes timing and waits for iOS runner prewarm after ope kind: 'device' as const, booted: true, }; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device, appName: 'Example', appBundleId: 'com.example.app' }), ); @@ -501,7 +513,7 @@ test('open --relaunch on iOS without existing session closes then opens target a test('close on macOS session stops runner and dismisses automation alert before delete', async () => { const sessionStore = makeSessionStore('agent-device-relaunch-close-'); const sessionName = 'macos-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { @@ -542,7 +554,7 @@ test('close on macOS session stops runner and dismisses automation alert before test('close on iOS simulator session retains runner and deletes the session', async () => { const sessionStore = makeSessionStore('agent-device-relaunch-close-'); const sessionName = 'ios-simulator-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { @@ -567,7 +579,7 @@ test('close on iOS simulator session retains runner and deletes the session', as test('close on iOS simulator with scoped simulator set retains runner and deletes the session', async () => { const sessionStore = makeSessionStore('agent-device-relaunch-close-'); const sessionName = 'ios-scoped-simulator-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { @@ -600,7 +612,7 @@ test('close on leased iOS simulator session stops runner before deleting session deviceKey: 'ios:sim-1', clientId: 'client-a', }); - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { @@ -636,7 +648,7 @@ test('close on leased iOS simulator session stops runner before deleting session test('close --shutdown on iOS simulator stops runner before deleting session', async () => { const sessionStore = makeSessionStore('agent-device-relaunch-close-'); const sessionName = 'ios-simulator-shutdown-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { @@ -662,7 +674,7 @@ test('close --shutdown on iOS simulator stops runner before deleting session', a test('close on iOS stops runner before app close dispatch and performs final idempotent stop', async () => { const sessionStore = makeSessionStore('agent-device-relaunch-close-'); const sessionName = 'ios-close-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { @@ -699,7 +711,7 @@ test('close on iOS stops runner before app close dispatch and performs fin test('close on iOS simulator retains runner while terminating app', async () => { const sessionStore = makeSessionStore('agent-device-relaunch-close-'); const sessionName = 'ios-simulator-close-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { @@ -736,7 +748,7 @@ test('close on iOS simulator retains runner while terminating app', async test('app-only close terminates an iOS simulator app without ending its session', async () => { const sessionStore = makeSessionStore('agent-device-relaunch-close-'); const sessionName = 'ios-simulator-app-only-close'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { @@ -775,7 +787,7 @@ test('app-only close terminates an iOS simulator app without ending its session' test('close on macOS stops runner before app close dispatch and dismisses automation alert', async () => { const sessionStore = makeSessionStore('agent-device-relaunch-close-'); const sessionName = 'macos-close-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { diff --git a/src/daemon/handlers/__tests__/session-relaunch-guards.test.ts b/src/daemon/handlers/__tests__/session-relaunch-guards.test.ts index e4ac9574d0..c36126c636 100644 --- a/src/daemon/handlers/__tests__/session-relaunch-guards.test.ts +++ b/src/daemon/handlers/__tests__/session-relaunch-guards.test.ts @@ -115,7 +115,7 @@ test('open --relaunch rejects Android app binary paths for active sessions', asy }); session.appName = 'com.example.app'; session.appBundleId = 'com.example.app'; - sessionStore.set('default', session); + sessionStore.publish('default', session); const response = await handleSessionCommands({ req: { @@ -162,7 +162,7 @@ test('open --relaunch rejects Android app binary paths before resolving a new de test('open on in-use device returns DEVICE_IN_USE before readiness checks', async () => { const sessionStore = makeSessionStore(); - sessionStore.set( + sessionStore.publish( 'busy-session', makeSession('busy-session', { platform: 'apple', @@ -225,7 +225,7 @@ test('open on device owned by recording session returns recording recovery hint' showTouches: false, recordOnlySession: true, }); - sessionStore.set('default', recordingSession); + sessionStore.publish('default', recordingSession); mockResolveTargetDevice.mockResolvedValue({ platform: 'apple', diff --git a/src/daemon/handlers/__tests__/session-runtime-command.test.ts b/src/daemon/handlers/__tests__/session-runtime-command.test.ts index 83690a8b3a..49bc41455e 100644 --- a/src/daemon/handlers/__tests__/session-runtime-command.test.ts +++ b/src/daemon/handlers/__tests__/session-runtime-command.test.ts @@ -96,7 +96,7 @@ test('runtime clear removes applied transport hints for the active app', async ( metroHost: '10.0.0.10', metroPort: 8081, }); - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, { platform: 'android', id: 'emulator-5554', @@ -146,7 +146,7 @@ test('runtime clear expires the ref frame at the admitted hint mutation boundary }), appBundleId: 'com.example.demo', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); sessionStore.setRuntimeHints(sessionName, { platform: 'android', metroHost: '10.0.2.2', @@ -190,7 +190,7 @@ test('runtime clear rejects a false runtime-hints fact before its one implementa metroHost: '10.0.0.10', metroPort: 8081, }); - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, device), appBundleId: 'com.example.demo', }); @@ -247,7 +247,7 @@ test('runtime gesture-viewport admits and binds the exact viewport operation onc mkdtempForTestSync('runtime-gesture-viewport'), 'runtime-gesture-viewport.log', ); - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { platform: 'android', diff --git a/src/daemon/handlers/__tests__/session-script-publication.test.ts b/src/daemon/handlers/__tests__/session-script-publication.test.ts index 6c4f289358..074e66896c 100644 --- a/src/daemon/handlers/__tests__/session-script-publication.test.ts +++ b/src/daemon/handlers/__tests__/session-script-publication.test.ts @@ -87,7 +87,7 @@ function request(outputPath?: string, force?: boolean): DaemonRequest { test('publishes without close, returns the path/count, and leaves a terminal live session', () => { const outputPath = path.join(root, 'screen-x.ad'); const session = armedSession(); - store.set('authoring', session); + store.publish('authoring', session); const response = handleSessionScriptPublication({ req: request(outputPath), @@ -120,7 +120,7 @@ test('no-clobber failure preserves bytes and armed state, then --force retries s const outputPath = path.join(root, 'screen-x.ad'); fs.writeFileSync(outputPath, 'original\n'); const session = armedSession(); - store.set('authoring', session); + store.publish('authoring', session); const refused = handleSessionScriptPublication({ req: request(outputPath), @@ -149,7 +149,7 @@ test('retargeting without --force clears force authorization from the previous t const session = armedSession({ scriptPublication: authoringPublication('armed', { path: originalPath, force: true }), }); - store.set('authoring', session); + store.publish('authoring', session); const response = handleSessionScriptPublication({ req: request(retargetPath), @@ -166,7 +166,7 @@ test('retargeting without --force clears force authorization from the previous t test('refuses unarmed and repair-owned sessions before filesystem work', () => { const outputPath = path.join(root, 'missing', 'screen-x.ad'); - store.set('authoring', makeIosSession('authoring')); + const unarmedRef = store.publish('authoring', makeIosSession('authoring')); const unarmed = handleSessionScriptPublication({ req: request(outputPath), sessionName: 'authoring', @@ -178,7 +178,8 @@ test('refuses unarmed and repair-owned sessions before filesystem work', () => { }); expect(fs.existsSync(path.dirname(outputPath))).toBe(false); - store.set( + store.retire(unarmedRef); + store.publish( 'authoring', armedSession({ scriptPublication: repairPublication('armed', { boundary: 0 }) }), ); @@ -196,7 +197,7 @@ test('refuses unarmed and repair-owned sessions before filesystem work', () => { test('rejects an explicitly empty destination path', () => { const session = armedSession(); - store.set('authoring', session); + store.publish('authoring', session); const response = handleSessionScriptPublication({ req: request(''), @@ -219,7 +220,7 @@ test('invalid destination guard remains armed and creates no target directory', { ts: 2, command: 'wait', positionals: ['stable'], flags: {} }, ], }); - store.set('authoring', session); + store.publish('authoring', session); const response = handleSessionScriptPublication({ req: request(outputPath), @@ -247,7 +248,7 @@ test('missing initial open is non-retriable within the armed session', () => { }, ], }); - store.set('authoring', session); + store.publish('authoring', session); const response = handleSessionScriptPublication({ req: request(outputPath), @@ -295,7 +296,7 @@ test('publishes leading-at typed values without mistaking them for session refs' }, ], }); - store.set('authoring', session); + store.publish('authoring', session); const response = handleSessionScriptPublication({ req: request(outputPath), @@ -330,7 +331,7 @@ test('refuses mutating find steps that cannot enforce target identity on replay' }, ], }); - store.set('authoring', session); + store.publish('authoring', session); const response = handleSessionScriptPublication({ req: request(outputPath), diff --git a/src/daemon/handlers/__tests__/session-selector-dispatch.test.ts b/src/daemon/handlers/__tests__/session-selector-dispatch.test.ts index cfdf9d8fd2..3b8750fc95 100644 --- a/src/daemon/handlers/__tests__/session-selector-dispatch.test.ts +++ b/src/daemon/handlers/__tests__/session-selector-dispatch.test.ts @@ -173,7 +173,7 @@ test('keyboard dismiss crosses the ADR 0014 seam while keyboard status preserves const { inspectFacts, bindDevice } = keyboardCapableRuntime(device); // dismiss mutates the device → frame expires. - sessionStore.set(sessionName, makeSession(sessionName, device)); + const dismissRef = sessionStore.publish(sessionName, makeSession(sessionName, device)); await handleSessionCommands({ req: { token: 't', @@ -192,7 +192,8 @@ test('keyboard dismiss crosses the ADR 0014 seam while keyboard status preserves expect(refFrameState(sessionStore.get(sessionName)!)).toBe('expired'); // status is a read-only probe → frame preserved (undefined === active). - sessionStore.set(sessionName, makeSession(sessionName, device)); + sessionStore.retire(dismissRef); + sessionStore.publish(sessionName, makeSession(sessionName, device)); await handleSessionCommands({ req: { token: 't', @@ -234,7 +235,7 @@ test('keyboard dismiss expires the frame before the invocation runs, even when i }, }); - sessionStore.set(sessionName, makeSession(sessionName, device)); + sessionStore.publish(sessionName, makeSession(sessionName, device)); await expect( handleSessionCommands({ req: { diff --git a/src/daemon/handlers/__tests__/snapshot-alert.test.ts b/src/daemon/handlers/__tests__/snapshot-alert.test.ts index f759a84beb..c6697c1334 100644 --- a/src/daemon/handlers/__tests__/snapshot-alert.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-alert.test.ts @@ -61,7 +61,7 @@ beforeEach(() => { test('alert accept retries a typed alert absence and succeeds on the second attempt', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-sim'; - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); let calls = 0; mockRunnerCommand.mockImplementation(async () => { @@ -85,7 +85,7 @@ test('alert accept retries a typed alert absence and succeeds on the second atte test('alert accept adds a scoped-snapshot hint after retrying alert-not-found failures', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-sim'; - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); mockRunnerCommand.mockRejectedValue(alertAbsence()); let thrown: unknown; @@ -103,7 +103,7 @@ test('alert accept adds a scoped-snapshot hint after retrying alert-not-found fa test('alert dismiss retries a typed absence whatever the message says', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-sim'; - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); let calls = 0; mockRunnerCommand.mockImplementation(async () => { diff --git a/src/daemon/handlers/__tests__/snapshot-handler-freshness.test.ts b/src/daemon/handlers/__tests__/snapshot-handler-freshness.test.ts index b9b340b0fe..7e709aa4bb 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler-freshness.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler-freshness.test.ts @@ -93,7 +93,7 @@ function makeAndroidTimeoutEvidenceSession(sessionName: string): SessionStore { createdAt: Date.now(), backend: 'android', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); return sessionStore; } @@ -165,7 +165,7 @@ test('snapshot annotations survive a deferred post-gesture capture into CLI JSON const snapshotQuality = { state: 'healthy', backend: 'tree' }; session.snapshot = { nodes: baselineNodes, createdAt: Date.now(), backend: 'android' }; session.postGestureStabilization = { action: 'swipe', positionals: [], markedAt: Date.now() }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockResolvedValue({ nodes: changedNodes, @@ -226,7 +226,7 @@ test('snapshot warns when recent snapshot node count collapses sharply', async ( createdAt: Date.now(), backend: 'android', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockResolvedValue( androidCapture( @@ -267,7 +267,7 @@ test('snapshot does not warn on expected node drop across presentation modes', a backend: 'xctest', presentationKey: buildSnapshotPresentationKey({ interactiveOnly: false }), }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockResolvedValue({ nodes: Array.from({ length: 8 }, (_, index) => ({ @@ -302,7 +302,7 @@ test('snapshot automatically retries stale Android trees after recent navigation const sessionStore = makeSessionStore(); const sessionName = 'android-stale-retries-to-fresh'; const session = makeAndroidFreshnessSession(sessionName, 'press', inboxBaselineNodes(24)); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture .mockResolvedValueOnce( @@ -340,7 +340,7 @@ test('snapshot warns when Android freshness retries still return the previous ro const sessionStore = makeSessionStore(); const sessionName = 'android-stale-after-press'; const session = makeAndroidFreshnessSession(sessionName, 'press', inboxBaselineNodes(24)); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockResolvedValue( androidCapture(androidTextRows(24, inboxRow), { rawNodeCount: 24, maxDepth: 2 }), @@ -367,7 +367,7 @@ test('snapshot warns when Android freshness retries still return the previous ro test('snapshot response includes normalized visibility metadata', async () => { const sessionStore = makeSessionStore(); const sessionName = 'android-visibility'; - sessionStore.set(sessionName, makeSession(sessionName, androidDevice)); + sessionStore.publish(sessionName, makeSession(sessionName, androidDevice)); legacyDispatchCapture.mockResolvedValue({ nodes: [ @@ -416,7 +416,7 @@ test('diff snapshot carries stale-tree warnings for recent Android presses', asy const sessionStore = makeSessionStore(); const sessionName = 'android-diff-stale-after-press'; const session = makeAndroidFreshnessSession(sessionName, 'press', inboxBaselineNodes(24)); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockResolvedValue( androidCapture(androidTextRows(24, inboxRow), { rawNodeCount: 24, maxDepth: 2 }), @@ -451,7 +451,7 @@ test('Android ref refresh mode does not retry narrow snapshots as sharp drops', 'press', buildNodes(androidTextRows(50, (row) => `Previous row ${row}`)), ); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockResolvedValue( androidCapture( diff --git a/src/daemon/handlers/__tests__/snapshot-handler-wait.test.ts b/src/daemon/handlers/__tests__/snapshot-handler-wait.test.ts index 809b64c98a..990bb1ffd2 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler-wait.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler-wait.test.ts @@ -82,7 +82,7 @@ async function runWaitCommand( positionals: string[], ) { const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, makeSession(sessionName, device, appAttach(device))); + sessionStore.publish(sessionName, makeSession(sessionName, device, appAttach(device))); return await handleSnapshotCommands({ req: snapshotRequest(sessionName, 'wait', { positionals }), sessionName, @@ -177,7 +177,7 @@ test('wait text on Android uses freshness-aware capture instead of one-shot snap const sessionStore = makeSessionStore(); const sessionName = 'android-wait-freshness'; const session = makeAndroidFreshnessSession(sessionName, 'press', inboxBaselineNodes(18)); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture .mockResolvedValueOnce( @@ -359,7 +359,7 @@ test('wait selector bypasses a fresh matching session snapshot', async () => { }, ], }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockResolvedValue({ nodes: [ { diff --git a/src/daemon/handlers/__tests__/snapshot-handler.test.ts b/src/daemon/handlers/__tests__/snapshot-handler.test.ts index fada858f56..255eb439a5 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler.test.ts @@ -69,7 +69,7 @@ beforeEach(() => { test('snapshot rejects @ref scope without existing session snapshot', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-sim'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { platform: 'apple', @@ -98,7 +98,7 @@ test('snapshot rejects @ref scope without existing session snapshot', async () = test('snapshot on iOS rejects sessions without a tracked app', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-sim-no-app'; - sessionStore.set(sessionName, makeSession(sessionName, iosSimulatorDevice)); + sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice)); const runtime = countingSnapshotRuntime(); const response = await handleSnapshotCommands({ @@ -128,7 +128,7 @@ test('snapshot on iOS without a tracked app carries the detected open command as ); const sessionStore = makeSessionStore(); const sessionName = 'ios-sim-no-app-hinted'; - sessionStore.set(sessionName, makeSession(sessionName, iosSimulatorDevice)); + sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice)); const response = await handleSnapshotCommands({ req: snapshotRequest(sessionName, 'snapshot'), @@ -155,7 +155,7 @@ test('snapshot on iOS without a tracked app carries the detected open command as test('snapshot on provider-backed iOS runs without a tracked app', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-cloud-no-app'; - sessionStore.set(sessionName, makeSession(sessionName, providerIosDevice)); + sessionStore.publish(sessionName, makeSession(sessionName, providerIosDevice)); setActiveProviderDeviceRuntimes([makeProviderRuntimeOwning(providerIosDevice)]); legacyDispatchCapture.mockResolvedValue({ nodes: [{ index: 0, depth: 0, type: 'XCUIElementTypeButton', label: 'Sign in' }], @@ -185,7 +185,7 @@ test('snapshot on provider-backed iOS runs without a tracked app', async () => { test('diff on local iOS still requires a tracked app', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-sim-no-app-diff'; - sessionStore.set(sessionName, makeSession(sessionName, iosSimulatorDevice)); + sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice)); const response = await handleSnapshotCommands({ req: snapshotRequest(sessionName, 'diff', { positionals: ['snapshot'] }), @@ -205,7 +205,7 @@ test('diff on local iOS still requires a tracked app', async () => { test('snapshot on iOS runs when the session tracks an app', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-sim-app'; - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, iosSimulatorDevice), appBundleId: 'org.reactnavigation.playground', }); @@ -243,7 +243,7 @@ test('snapshot re-activates a complete frame; diff preserves it (ADR 0014)', asy }; // A prior device action expired the frame. expireRefFrame(session); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockResolvedValue({ nodes: [{ index: 0, depth: 0, type: 'android.widget.Button', label: 'Fresh' }], truncated: false, @@ -295,7 +295,7 @@ async function runVersionedRefsCommand(params: { function makeVersionedRefsScenario(sessionName: string) { const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, makeSession(sessionName, androidDevice)); + sessionStore.publish(sessionName, makeSession(sessionName, androidDevice)); legacyDispatchCapture.mockResolvedValue({ nodes: [{ index: 0, depth: 0, type: 'android.widget.Button', label: 'Fresh' }], truncated: false, @@ -384,7 +384,7 @@ test('daemon-private snapshot observation advances capture state without publish test('snapshot surfaces filtered-to-zero Android guidance for interactive snapshots', async () => { const sessionStore = makeSessionStore(); const sessionName = 'android-empty-interactive'; - sessionStore.set(sessionName, makeSession(sessionName, androidDevice)); + sessionStore.publish(sessionName, makeSession(sessionName, androidDevice)); legacyDispatchCapture.mockResolvedValue(androidCapture([], { rawNodeCount: 42, maxDepth: 8 })); diff --git a/src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts b/src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts index 57ca82d23a..ccd14733c9 100644 --- a/src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-scoped-refs.test.ts @@ -18,7 +18,7 @@ test('snapshot resolves @ref scope with the stored source after scoped output re const sessionStore = makeSessionStore('agent-device-snapshot-scoped-refs-'); const sessionName = 'android-ref-scope-repeat'; const session = makeAndroidSession(sessionName, { snapshot: androidRefScopeSourceSnapshot() }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockResolvedValue({ nodes: scopedScriptErrorNodes(), @@ -46,7 +46,7 @@ test('a mutation clears scoped-snapshot lineage so a repeated snapshot -s @ref c const sessionStore = makeSessionStore('agent-device-snapshot-scoped-refs-'); const sessionName = 'android-ref-scope-broken-by-mutation'; const session = makeAndroidSession(sessionName, { snapshot: androidRefScopeSourceSnapshot() }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockResolvedValue({ nodes: scopedScriptErrorNodes(), @@ -75,7 +75,7 @@ test('empty @ref-scoped snapshot output does not replace the stored session snap const sessionStore = makeSessionStore('agent-device-snapshot-scoped-refs-'); const sessionName = 'android-empty-scope-preserve'; const session = makeAndroidSession(sessionName, { snapshot: currentScreenSnapshot() }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockResolvedValue({ nodes: [], diff --git a/src/daemon/handlers/__tests__/snapshot-settings-handler.test.ts b/src/daemon/handlers/__tests__/snapshot-settings-handler.test.ts index 12f0e4c36f..4415941789 100644 --- a/src/daemon/handlers/__tests__/snapshot-settings-handler.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-settings-handler.test.ts @@ -80,7 +80,7 @@ beforeEach(() => { test('settings rejects unsupported iOS physical devices', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-device'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { platform: 'apple', @@ -109,7 +109,7 @@ test('settings rejects unsupported iOS physical devices', async () => { test('settings clear-app-state dispatches explicit app id without an active app session', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-clear-state'; - sessionStore.set(sessionName, makeSession(sessionName, iosSimulatorDevice)); + sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice)); const response = await handleSnapshotCommands({ req: snapshotRequest(sessionName, 'settings', { @@ -131,7 +131,7 @@ test('settings clear-app-state dispatches explicit app id without an active app test('settings clear-app-state rejects missing app id when no app session is bound', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-clear-state-missing-app'; - sessionStore.set(sessionName, makeSession(sessionName, iosSimulatorDevice)); + sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice)); const response = await handleSnapshotCommands({ req: snapshotRequest(sessionName, 'settings', { positionals: ['clear-app-state'] }), @@ -151,7 +151,7 @@ test('settings clear-app-state rejects missing app id when no app session is bou test('settings reset-keychain dispatches without an app id or active app session', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-reset-keychain'; - sessionStore.set(sessionName, makeSession(sessionName, iosSimulatorDevice)); + sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice)); const response = await handleSnapshotCommands({ req: snapshotRequest(sessionName, 'settings', { @@ -172,7 +172,7 @@ test('settings reset-keychain dispatches without an app id or active app session test('settings reset-keychain rejects an extra app argument instead of dropping it', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-reset-keychain-extra-arg'; - sessionStore.set(sessionName, makeSession(sessionName, iosSimulatorDevice)); + sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice)); const response = await handleSnapshotCommands({ req: snapshotRequest(sessionName, 'settings', { @@ -193,7 +193,7 @@ test('settings reset-keychain rejects an extra app argument instead of dropping test('settings text-size reads the category the owner holds without mutating anything', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-text-size-read'; - sessionStore.set(sessionName, makeSession(sessionName, iosSimulatorDevice)); + sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice)); const response = await handleSnapshotCommands({ req: snapshotRequest(sessionName, 'settings', { positionals: ['text-size'] }), @@ -216,7 +216,7 @@ test('settings text-size reads the category the owner holds without mutating any test('settings text-size refuses the macOS host on both legs with the same code', async () => { const sessionStore = makeSessionStore(); const sessionName = 'macos-text-size-read'; - sessionStore.set(sessionName, makeSession(sessionName, macOsDevice)); + sessionStore.publish(sessionName, makeSession(sessionName, macOsDevice)); const read = await handleSnapshotCommands({ req: snapshotRequest(sessionName, 'settings', { positionals: ['text-size'] }), @@ -247,7 +247,7 @@ test('settings text-size applies a ladder category through the write leg', async const sessionName = 'ios-text-size-write'; const session = makeSession(sessionName, iosSimulatorDevice); activateCompleteRefFrame(session); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await handleSnapshotCommands({ req: snapshotRequest(sessionName, 'settings', { @@ -278,7 +278,7 @@ test('settings text-size refuses an Apple leaf with no content size before it ex const sessionName = 'tvos-text-size'; const session = makeSession(sessionName, tvOsSimulatorDevice); activateCompleteRefFrame(session); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); for (const positionals of [['text-size'], ['text-size', 'large']]) { const response = await handleSnapshotCommands({ @@ -304,7 +304,7 @@ test('settings text-size refuses an Apple leaf with no content size before it ex test('settings text-size refuses an off-ladder category with the whole ladder', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-text-size-invalid'; - sessionStore.set(sessionName, makeSession(sessionName, iosSimulatorDevice)); + sessionStore.publish(sessionName, makeSession(sessionName, iosSimulatorDevice)); const response = await handleSnapshotCommands({ req: snapshotRequest(sessionName, 'settings', { positionals: ['text-size', 'gigantic'] }), @@ -349,7 +349,7 @@ test('settings usage hint documents canonical faceid states', async () => { test('settings on macOS rejects wifi before dispatch with explicit subset guidance', async () => { const sessionStore = makeSessionStore(); const sessionName = 'macos-settings-wifi'; - sessionStore.set(sessionName, makeSession(sessionName, macOsDevice)); + sessionStore.publish(sessionName, makeSession(sessionName, macOsDevice)); const response = await handleSnapshotCommands({ req: snapshotRequest(sessionName, 'settings', { positionals: ['wifi', 'on'] }), diff --git a/src/daemon/handlers/__tests__/trace-runtime.test.ts b/src/daemon/handlers/__tests__/trace-runtime.test.ts index d06b58c472..2966e5bac0 100644 --- a/src/daemon/handlers/__tests__/trace-runtime.test.ts +++ b/src/daemon/handlers/__tests__/trace-runtime.test.ts @@ -22,7 +22,7 @@ function fixture() { createdAt: Date.now(), actions: [], }; - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); return { root, session, sessionStore }; } diff --git a/src/daemon/handlers/__tests__/wait-landmark-recording.test.ts b/src/daemon/handlers/__tests__/wait-landmark-recording.test.ts index fca72e152e..5b8124c022 100644 --- a/src/daemon/handlers/__tests__/wait-landmark-recording.test.ts +++ b/src/daemon/handlers/__tests__/wait-landmark-recording.test.ts @@ -89,7 +89,7 @@ async function runWait(options: { recording?: boolean; req?: DaemonRequest } = { 'default', options.recording ? { scriptPublication: authoringPublication('armed') } : {}, ); - sessionStore.set('default', session); + sessionStore.publish('default', session); const response = await dispatchWaitViaRuntime({ req: options.req ?? waitReq(), sessionName: 'default', diff --git a/src/daemon/interaction/internal/__tests__/find-production-route.test.ts b/src/daemon/interaction/internal/__tests__/find-production-route.test.ts index dd39762ca3..35adfffcda 100644 --- a/src/daemon/interaction/internal/__tests__/find-production-route.test.ts +++ b/src/daemon/interaction/internal/__tests__/find-production-route.test.ts @@ -93,7 +93,7 @@ async function runFindThroughLeaf(options: { const sessionStore = makeSessionStore(); const sessionName = 'default'; const session = makeSession(sessionName); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockDispatch.mockImplementation(async (_device, command) => command === 'snapshot' ? { nodes: freshFindTree(), backend: 'xctest' } : {}, @@ -110,7 +110,6 @@ async function runFindThroughLeaf(options: { const current = sessionStore.get(sessionName); if (current) { current.snapshot = divergedSessionTree(); - sessionStore.set(sessionName, current); } } // The real leaf, not a stub. diff --git a/src/daemon/interaction/internal/__tests__/find-read-only.test.ts b/src/daemon/interaction/internal/__tests__/find-read-only.test.ts index 49e2fd8dd1..dad3650ff6 100644 --- a/src/daemon/interaction/internal/__tests__/find-read-only.test.ts +++ b/src/daemon/interaction/internal/__tests__/find-read-only.test.ts @@ -175,7 +175,7 @@ test('read-only find while recording is intentionally deferred from target-v1 ev const sessionStore = makeSessionStore(); const sessionName = 'default'; const session = makeAuthoringSession(sessionName); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockDispatch.mockImplementation(async (_device, command) => { if (command === 'snapshot') { return { diff --git a/src/daemon/interaction/internal/__tests__/find-single-bind.test.ts b/src/daemon/interaction/internal/__tests__/find-single-bind.test.ts index 51f4707d27..1acf192fae 100644 --- a/src/daemon/interaction/internal/__tests__/find-single-bind.test.ts +++ b/src/daemon/interaction/internal/__tests__/find-single-bind.test.ts @@ -25,7 +25,7 @@ async function runMutatingFind(positionals: string[], node: Record command === 'snapshot' ? { nodes: [node] } : {}, ); diff --git a/src/daemon/interaction/internal/__tests__/find-target-activation-disclosure.test.ts b/src/daemon/interaction/internal/__tests__/find-target-activation-disclosure.test.ts index c795e37258..2ed86a118b 100644 --- a/src/daemon/interaction/internal/__tests__/find-target-activation-disclosure.test.ts +++ b/src/daemon/interaction/internal/__tests__/find-target-activation-disclosure.test.ts @@ -80,7 +80,7 @@ async function findClick(captures: Record[] | CaptureScript, af const session = makeIosSession('default', { appBundleId: 'com.example.app' }); if (afterScroll) markDeferredInteractionOutcome({ session, command: 'scroll', positionals: [], flags: {} }); - sessionStore.set('default', session); + sessionStore.publish('default', session); let call = 0; legacyDispatchCapture.mockImplementation( async (_device, _command, _positionals, _out, context) => diff --git a/src/daemon/interaction/internal/__tests__/find-touch-runtime-fixture.ts b/src/daemon/interaction/internal/__tests__/find-touch-runtime-fixture.ts index 987c6e70f5..fc71d14823 100644 --- a/src/daemon/interaction/internal/__tests__/find-touch-runtime-fixture.ts +++ b/src/daemon/interaction/internal/__tests__/find-touch-runtime-fixture.ts @@ -70,7 +70,7 @@ export async function runFindClickScenario(options: { const sessionStore = makeSessionStore(); const sessionName = 'default'; const session = options.session ?? makeSession(sessionName); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); if (options.nodes !== undefined) { mockDispatch.mockImplementation(async (_device, command) => { diff --git a/src/daemon/interaction/internal/__tests__/interaction-capture-disclosure.test.ts b/src/daemon/interaction/internal/__tests__/interaction-capture-disclosure.test.ts index 03c0e94f7d..93b7160a79 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-capture-disclosure.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-capture-disclosure.test.ts @@ -55,7 +55,7 @@ async function pressSelector(params: { captureCalls: { count: number }; }) { const sessionStore = makeSessionStore(); - sessionStore.set(params.sessionName, makeSession(params.sessionName)); + sessionStore.publish(params.sessionName, makeSession(params.sessionName)); const response = await handleInteractionCommands({ req: { token: 't', @@ -106,7 +106,7 @@ test('a press that consumes no capture is not disclosed against an older tree', const sessionStore = makeSessionStore(); const session = makeSession(sessionName); session.snapshot = capturedTree({ sessionName, targetActivation: FACT }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await handleInteractionCommands({ req: { @@ -144,7 +144,7 @@ async function pressAfterUnsettledScroll(selector: string) { const sessionStore = makeSessionStore(); const session = makeSession('default'); markDeferredInteractionOutcome({ session, command: 'scroll', positionals: ['down'], flags: {} }); - sessionStore.set('default', session); + sessionStore.publish('default', session); let call = 0; legacyDispatchCapture.mockImplementation(async () => { call += 1; diff --git a/src/daemon/interaction/internal/__tests__/interaction-common.test.ts b/src/daemon/interaction/internal/__tests__/interaction-common.test.ts index 9ee05da84a..3835266ac0 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-common.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-common.test.ts @@ -38,7 +38,7 @@ test('parameterized fill scrubs backend and nested settle echoes at the response const placeholder = '${PASSWORD}'; const sessionStore = makeSessionStore(); const session = makeAuthoringSession('parameterized-fill'); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const payload = { text: secret, message: `prefix${secret}suffix`, @@ -148,7 +148,7 @@ test('parameterized fill scrubs concatenated backend values and object keys thro createdAt: Date.now(), backend: 'xctest', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockImplementation(async (_device, command) => command === 'fill' ? { @@ -209,7 +209,7 @@ test('parameterized fill collapses whitespace-only backend echoes through the ha createdAt: Date.now(), backend: 'xctest', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockResolvedValue({ [`prefix${secret}suffix`]: { [`key${secret}tail`]: `value${secret}tail`, @@ -255,7 +255,7 @@ test.each([ const placeholder = `\${${recordAs}}`; const sessionStore = makeSessionStore(); const session = makeAuthoringSession(`parameterized-overlap-${recordAs}`); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const payload = { text: literal, refLabel: `prefix${literal}suffix`, diff --git a/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts b/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts index 70092d2dff..8e11181547 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-dispatch-disclosure.test.ts @@ -87,7 +87,7 @@ async function press({ createdAt: Date.now(), backend: 'xctest', }; - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const response = await routeInteraction({ req: { token: 't', session: session.name, command, positionals, flags }, sessionName: session.name, @@ -184,7 +184,7 @@ async function swipeRefusedOnSecondRepetition(): Promise { } const sessionStore = makeSessionStore(); const session = makeSession('dispatch-disclosure-swipe'); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const response = await routeInteraction({ req: { token: 't', @@ -216,7 +216,7 @@ async function pressThenForegroundReadRefused(): Promise { appBundleId: 'com.example.app', }); const sessionStore = makeSessionStore(); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const readRefusal = new AppError('COMMAND_FAILED', 'adb device offline', { dispatched: 'no' }); const androidObservation: AndroidObservationAdapter = { ...clearAndroidObservationFixture, @@ -325,7 +325,7 @@ test('a read-only command discloses no over a producer verdict it throws', async test('a plain Error a backend throws reaches the wire with dispatched unknown', async () => { const sessionStore = makeSessionStore(); const session = makeSession('dispatch-disclosure-plain-error'); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const bindings = getRuntimeBindings(); const bindDevice = vi.fn(async () => { throw new Error('socket hang up'); diff --git a/src/daemon/interaction/internal/__tests__/interaction-gesture-drag.test.ts b/src/daemon/interaction/internal/__tests__/interaction-gesture-drag.test.ts index 593cffce83..79bc1e2b24 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-gesture-drag.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-gesture-drag.test.ts @@ -89,7 +89,7 @@ test('recorded ref drag dispatches once and stores portable selectors with both const sessionStore = makeSessionStore(); const sessionName = 'recorded-drag'; const session = makeDragSession(sessionName); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await runDrag(sessionStore, sessionName); @@ -140,7 +140,7 @@ test('recorded ref drag dispatches once and stores portable selectors with both test('a second ref drag is rejected before dispatch after the first drag expires the frame', async () => { const sessionStore = makeSessionStore(); const sessionName = 'stale-drag'; - sessionStore.set(sessionName, makeDragSession(sessionName)); + sessionStore.publish(sessionName, makeDragSession(sessionName)); expect((await runDrag(sessionStore, sessionName))?.ok).toBe(true); const response = await runDrag(sessionStore, sessionName); diff --git a/src/daemon/interaction/internal/__tests__/interaction-get.test.ts b/src/daemon/interaction/internal/__tests__/interaction-get.test.ts index 0b287b4cf7..9b35dba6fc 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-get.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-get.test.ts @@ -92,7 +92,7 @@ test('get text prefers underlying value for text surfaces and avoids recording g backend: 'xctest', producer: 'apple-runner', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockRejectedValue( new Error('dispatch should not be called for snapshot-derived get text'), @@ -143,7 +143,7 @@ test('get text uses backend read expansion when the resolved node has a rect', a backend: 'xctest', producer: 'apple-runner', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockReadTextAtPoint.mockResolvedValue( elementTextRead('package com.example.app\nclass MainActivity {}'), @@ -192,7 +192,7 @@ test('get text answers from the captured tree when the bound owner advertises no backend: 'xctest', producer: 'apple-runner', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); elementReadFixtureState.readTextAtPointAvailable = false; const response = await handleInteractionCommands({ @@ -226,7 +226,10 @@ test('get text answers from the captured tree when the bound owner advertises no test('an eligible direct iOS selector cannot operate before admission', async () => { const sessionStore = makeSessionStore(); const sessionName = 'get-text-direct-before-admission'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); elementReadFixtureState.captureSnapshotAvailable = false; mockRunAppleRunnerCommand.mockResolvedValue({ found: true, @@ -262,7 +265,10 @@ test('an eligible direct iOS selector cannot operate before admission', async () test('get text simple iOS id selector resolves through the bound capture, not a runner query', async () => { const sessionStore = makeSessionStore(); const sessionName = 'get-text-ios-direct-selector'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); legacyDispatchCapture.mockResolvedValue({ backend: 'xctest', producer: 'apple-runner', @@ -320,7 +326,10 @@ test('get text simple iOS id selector resolves through the bound capture, not a test('get text iOS label selector uses snapshot disambiguation instead of runner query', async () => { const sessionStore = makeSessionStore(); const sessionName = 'get-text-ios-label-selector'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); legacyDispatchCapture.mockResolvedValue({ backend: 'xctest', producer: 'apple-runner', @@ -393,7 +402,7 @@ test('get text iOS label selector uses snapshot disambiguation instead of runner test('is visible preserves CLI snapshot flags during runtime snapshot capture', async () => { const sessionStore = makeSessionStore(); const sessionName = 'snapshot-flags'; - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command !== 'snapshot') throw new Error(`unexpected command: ${command}`); @@ -456,7 +465,7 @@ test('is visible reuses fresh cached iOS snapshots with rects', async () => { backend: 'xctest', producer: 'apple-runner', }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockRejectedValue(new Error('unexpected fresh snapshot')); const response = await handleInteractionCommands({ @@ -490,7 +499,7 @@ test('is visible recaptures web snapshots when cached nodes may lack rects', asy }, { snapshotInteractiveOnly: false }, ); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockResolvedValue( makeVisibleButtonSnapshot('Submit order', { backend: 'web', @@ -528,7 +537,10 @@ test('is visible recaptures web snapshots when cached nodes may lack rects', asy test('a failing is predicate is COMMAND_FAILED, never a zero-exit pass', async () => { const sessionStore = makeSessionStore(); const sessionName = 'is-selected-ios-direct-selector-false'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); mockRunAppleRunnerCommand.mockResolvedValue({ found: true, nodes: [ @@ -570,7 +582,7 @@ test('a failing is predicate is COMMAND_FAILED, never a zero-exit pass', async ( test('is visible passes for list text that inherits viewport visibility from an ancestor', async () => { const sessionStore = makeSessionStore(); const sessionName = 'visible-list-item'; - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command !== 'snapshot') throw new Error(`unexpected command: ${command}`); @@ -623,7 +635,7 @@ test('is visible passes for list text that inherits viewport visibility from an test('is visible fails for nodes outside the current viewport', async () => { const sessionStore = makeSessionStore(); const sessionName = 'visible-offscreen'; - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command !== 'snapshot') throw new Error(`unexpected command: ${command}`); @@ -669,7 +681,7 @@ test('is visible fails for nodes outside the current viewport', async () => { test('is reports Android permission dialog blocker when app content assertion fails', async () => { const sessionStore = makeSessionStore(); const sessionName = 'android-permission-blocked'; - sessionStore.set( + sessionStore.publish( sessionName, makeBaseAndroidSession(sessionName, { appBundleId: 'com.example.demo' }), ); @@ -734,7 +746,7 @@ test('ADR 0014 evidence #17: get text @ref reads the retained frame tree, not a backend: 'xctest', producer: 'apple-runner', }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockRejectedValue(new Error('get text @ref must not recapture')); // Resolves against the frame tree's @e2 (Continue), never the observation's @@ -764,7 +776,7 @@ test('get text @ref warns while the frame is expired (retained evidence still re // A device action expired the frame; the read still resolves against the // retained frame tree and stays fail-open with a warning (ADR 0014). expireRefFrame(session); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockRejectedValue( new Error('dispatch should not be called for snapshot-derived get text'), ); @@ -782,7 +794,7 @@ test('get text with a pinned stale ref gets the precise warning', async () => { const sessionName = 'pinned-get-text'; const session = makeStaleRefSession(sessionName); session.snapshotGeneration = 4; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockRejectedValue( new Error('dispatch should not be called for snapshot-derived get text'), ); diff --git a/src/daemon/interaction/internal/__tests__/interaction-ios-tap-outcome.test.ts b/src/daemon/interaction/internal/__tests__/interaction-ios-tap-outcome.test.ts index 30ed2bf4b5..95827268d6 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-ios-tap-outcome.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-ios-tap-outcome.test.ts @@ -109,7 +109,7 @@ test('a changed post-action capture corroborates an iOS tap reported as failed', appBundleId: 'com.example.app', snapshot: snapshot(profileNodes), }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'press') { throw new AppError( @@ -139,7 +139,7 @@ test('an unchanged post-action capture keeps a failed iOS tap failed', async () appBundleId: 'com.example.app', snapshot: snapshot(profileNodes), }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'press') { throw new AppError( @@ -165,7 +165,7 @@ test('a private-ax baseline pins the corroboration probe to private-ax', async ( // correctly refuses to compare, and a landed tap surfaces as a failure. const sessionName = 'ios-private-ax-pinned-corroboration'; const sessionStore = makeSessionStore(); - sessionStore.set( + sessionStore.publish( sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app', @@ -206,7 +206,7 @@ test('a canonical selector capture replaces a raw baseline before corroboration' // capture at all) and the recorded failure surfaces unchanged. const sessionName = 'ios-raw-baseline-no-corroboration'; const sessionStore = makeSessionStore(); - sessionStore.set( + sessionStore.publish( sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app', @@ -233,7 +233,7 @@ test('a canonical selector capture replaces a raw baseline before corroboration' test('a tree baseline does not pin the corroboration probe backend', async () => { const sessionName = 'ios-tree-baseline-unpinned-corroboration'; const sessionStore = makeSessionStore(); - sessionStore.set( + sessionStore.publish( sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app', @@ -267,7 +267,7 @@ test('a tree baseline does not pin the corroboration probe backend', async () => test('a changed capture from a different iOS backend keeps the tap failure', async () => { const sessionName = 'ios-cross-backend-tap-corroboration'; const sessionStore = makeSessionStore(); - sessionStore.set( + sessionStore.publish( sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app', @@ -301,7 +301,7 @@ test('a producer or generation switch cannot corroborate a failed tap', async () appBundleId: 'com.example.app', snapshot: baseline, }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const after = snapshot(imageViewerNodes); after.comparisonKey = 'apple-runner:launch-a'; @@ -332,7 +332,7 @@ test('a capture of a system surface cannot corroborate a tap taken against the a appBundleId: 'com.example.app', snapshot: baseline, }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const after = snapshot(imageViewerNodes); after.comparisonKey = systemSurfaceCaptureComparisonKey(IOS_SIMULATOR.id); after.iosSystemSurfaceBundleId = 'com.apple.SafariViewService'; @@ -354,7 +354,7 @@ test('a capture of a system surface cannot corroborate a tap taken against the a test('a sparse changed capture keeps the tap failure', async () => { const sessionName = 'ios-sparse-tap-corroboration'; const sessionStore = makeSessionStore(); - sessionStore.set( + sessionStore.publish( sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app', @@ -387,7 +387,7 @@ test('a sparse changed capture keeps the tap failure', async () => { test('a corroboration capture failure keeps the tap failure', async () => { const sessionName = 'ios-capture-failed-tap-corroboration'; const sessionStore = makeSessionStore(); - sessionStore.set( + sessionStore.publish( sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app', @@ -417,7 +417,7 @@ test('the canonical selector capture aligns presentation before corroboration', const sessionStore = makeSessionStore(); const baseline = snapshot(profileNodes); baseline.presentationKey = 'unreadable-presentation'; - sessionStore.set( + sessionStore.publish( sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app', @@ -446,7 +446,7 @@ test('corroborates a tap when the request carries no flags and the baseline used const sessionStore = makeSessionStore(); const baseline = snapshot(profileNodes); baseline.presentationKey = buildSnapshotPresentationKey({ depth: 2, raw: false }); - sessionStore.set( + sessionStore.publish( sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app', @@ -497,7 +497,7 @@ test('a changed capture after ordinary agent turn latency still corroborates the const sessionStore = makeSessionStore(); const baseline = snapshot(profileNodes); baseline.createdAt = Date.now() - 6_000; - sessionStore.set( + sessionStore.publish( sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app', @@ -530,7 +530,7 @@ test('the canonical selector capture replaces a stale baseline before corroborat const sessionStore = makeSessionStore(); const baseline = snapshot(profileNodes); baseline.createdAt = Date.now() - 15_001; - sessionStore.set( + sessionStore.publish( sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app', @@ -560,7 +560,7 @@ test('the canonical selector capture replaces a keyless baseline before corrobor const sessionStore = makeSessionStore(); const baseline = snapshot(profileNodes); baseline.presentationKey = undefined; - sessionStore.set( + sessionStore.publish( sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app', @@ -588,7 +588,7 @@ test('the canonical selector capture replaces a keyless baseline before corrobor test('runtime-resolved taps use the same corroboration boundary', async () => { const sessionName = 'ios-runtime-tap-corroboration'; const sessionStore = makeSessionStore(); - sessionStore.set( + sessionStore.publish( sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app', @@ -622,7 +622,7 @@ test('runtime-resolved taps use the same corroboration boundary', async () => { test('runtime coordinate taps use the same corroboration boundary', async () => { const sessionName = 'ios-runtime-coordinate-corroboration'; const sessionStore = makeSessionStore(); - sessionStore.set( + sessionStore.publish( sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app', @@ -654,7 +654,7 @@ test('corroborated runtime taps retain target evidence through save and replay', const root = mkdtempForTestSync('agent-device-ios-tap-replay-'); const sessionName = 'ios-recorded-tap'; const sessionStore = makeSessionStore(); - sessionStore.set( + sessionStore.publish( sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app', @@ -707,7 +707,7 @@ test('corroborated runtime taps retain target evidence through save and replay', recording = false; const replaySessionName = 'ios-replayed-tap'; const replayStore = makeSessionStore(); - replayStore.set( + replayStore.publish( replaySessionName, makeIosSession(replaySessionName, { appBundleId: 'com.example.app', diff --git a/src/daemon/interaction/internal/__tests__/interaction-settle-private-ax-route.test.ts b/src/daemon/interaction/internal/__tests__/interaction-settle-private-ax-route.test.ts index f1846e7c16..97e062f404 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-settle-private-ax-route.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-settle-private-ax-route.test.ts @@ -61,7 +61,7 @@ beforeEach(() => { test('daemon press --settle pins private-ax on emitted snapshot runner requests', async () => { const sessionName = 'press-settle-private-ax-route'; const sessionStore = makeSessionStore(); - sessionStore.set( + sessionStore.publish( sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.fixture', diff --git a/src/daemon/interaction/internal/__tests__/interaction-settle.test.ts b/src/daemon/interaction/internal/__tests__/interaction-settle.test.ts index 48cc20728e..a8b8e4a7a1 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-settle.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-settle.test.ts @@ -97,7 +97,7 @@ function seedSession(sessionName: string, sessionStore: ReturnType { const sessionStore = makeSessionStore(); const sessionName = 'recording-press'; - sessionStore.set(sessionName, makeSessionWithSnapshot(sessionName, { recording: true })); + sessionStore.publish(sessionName, makeSessionWithSnapshot(sessionName, { recording: true })); // verify:true forces full runtime resolution, which captures node/tree. const response = await runCommand(sessionStore, sessionName, 'press', ['@e1'], { verify: true }); @@ -131,7 +131,7 @@ test('press @ref while recording attaches target-v1 evidence to the recorded act test('press @ref without recording never computes target-v1 evidence', async () => { const sessionStore = makeSessionStore(); const sessionName = 'non-recording-press'; - sessionStore.set(sessionName, makeSessionWithSnapshot(sessionName, { recording: false })); + sessionStore.publish(sessionName, makeSessionWithSnapshot(sessionName, { recording: false })); const response = await runCommand(sessionStore, sessionName, 'press', ['@e1'], { verify: true }); @@ -145,7 +145,7 @@ test('press @ref without recording never computes target-v1 evidence', async () test('get text @ref while recording attaches target-v1 evidence to the recorded action, never to session history payloads', async () => { const sessionStore = makeSessionStore(); const sessionName = 'recording-get-ref'; - sessionStore.set(sessionName, makeSessionWithSnapshot(sessionName, { recording: true })); + sessionStore.publish(sessionName, makeSessionWithSnapshot(sessionName, { recording: true })); const response = await runCommand(sessionStore, sessionName, 'get', ['text', '@e1']); @@ -173,7 +173,7 @@ test('get text @ref while recording attaches target-v1 evidence to the recorded test('get text @ref without recording never computes target-v1 evidence', async () => { const sessionStore = makeSessionStore(); const sessionName = 'non-recording-get-ref'; - sessionStore.set(sessionName, makeSessionWithSnapshot(sessionName, { recording: false })); + sessionStore.publish(sessionName, makeSessionWithSnapshot(sessionName, { recording: false })); const response = await runCommand(sessionStore, sessionName, 'get', ['text', '@e1']); @@ -186,7 +186,7 @@ test('get text simple iOS id selector while recording skips the direct runner qu const sessionStore = makeSessionStore(); const sessionName = 'recording-get-direct-gate'; const session = makeAuthoringSession(sessionName, { appBundleId: 'com.example.app' }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'snapshot') { return { @@ -325,7 +325,7 @@ test('press on an identity-empty container: container-based daemon response, des // recording) without Android's real-adb dialog-readiness probes, which // would burn wall-clock time this unit lane must not spend. const session = makeAuthoringSession(sessionName, { appBundleId: 'com.example.app' }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); legacyDispatchCapture.mockImplementation(async (_device, command) => { if (command === 'snapshot') { return { backend: 'xctest', nodes: IDENTITY_EMPTY_ROW_NODES }; @@ -370,7 +370,7 @@ function mockSnapshotWithSaveButton() { test('is visible while recording attaches target-v1 evidence and strips the resolution payload everywhere', async () => { const sessionStore = makeSessionStore(); const sessionName = 'recording-is'; - sessionStore.set(sessionName, makeSessionWithSnapshot(sessionName, { recording: true })); + sessionStore.publish(sessionName, makeSessionWithSnapshot(sessionName, { recording: true })); mockSnapshotWithSaveButton(); const response = await runCommand(sessionStore, sessionName, 'is', ['visible', 'label="Save"']); @@ -402,7 +402,7 @@ test('is visible while recording attaches target-v1 evidence and strips the reso test('is exists while recording stays intentionally unannotated (deferred coverage)', async () => { const sessionStore = makeSessionStore(); const sessionName = 'recording-is-exists'; - sessionStore.set(sessionName, makeSessionWithSnapshot(sessionName, { recording: true })); + sessionStore.publish(sessionName, makeSessionWithSnapshot(sessionName, { recording: true })); mockSnapshotWithSaveButton(); const response = await runCommand(sessionStore, sessionName, 'is', ['exists', 'label="Save"']); @@ -416,7 +416,7 @@ test('is exists while recording stays intentionally unannotated (deferred covera test('is absent while recording stays an ordinary unannotated observation', async () => { const sessionStore = makeSessionStore(); const sessionName = 'recording-is-absent'; - sessionStore.set(sessionName, makeSessionWithSnapshot(sessionName, { recording: true })); + sessionStore.publish(sessionName, makeSessionWithSnapshot(sessionName, { recording: true })); mockSnapshotWithSaveButton(); const response = await runCommand(sessionStore, sessionName, 'is', [ @@ -447,7 +447,7 @@ test('is absent while recording stays an ordinary unannotated observation', asyn test('is visible without recording never computes target-v1 evidence', async () => { const sessionStore = makeSessionStore(); const sessionName = 'non-recording-is'; - sessionStore.set(sessionName, makeSessionWithSnapshot(sessionName, { recording: false })); + sessionStore.publish(sessionName, makeSessionWithSnapshot(sessionName, { recording: false })); mockSnapshotWithSaveButton(); const response = await runCommand(sessionStore, sessionName, 'is', ['visible', 'label="Save"']); diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-android-freshness.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-android-freshness.test.ts index 7a1296b902..70669107e3 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-android-freshness.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-android-freshness.test.ts @@ -87,7 +87,7 @@ test('press @ref refreshes Android snapshot when freshness tracking is active', baselineCount: 1, routeComparable: false, }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockCaptureSnapshotForSession.mockResolvedValue({ nodes: [ @@ -159,7 +159,7 @@ test('ADR 0014: Android freshness cannot retarget an admitted ref by positional baselineCount: 1, routeComparable: false, }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); // The freshness refresh returns a DIFFERENT element at @e1's index — after // navigation the button at that position is now "Cancel", not "Continue". @@ -224,7 +224,7 @@ test('press @ref falls back to cached Android ref when freshness refresh fails', baselineCount: 1, routeComparable: true, }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockCaptureSnapshotForSession.mockRejectedValueOnce(new Error('uiautomator timeout')); @@ -293,7 +293,7 @@ test('coordinate press preserves Android route freshness from last comparable sn backend: 'android', comparisonSafe: false, }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await handleInteractionCommands({ req: { diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-android-readiness.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-android-readiness.test.ts index 56642c945d..df7be26605 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-android-readiness.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-android-readiness.test.ts @@ -100,7 +100,7 @@ test('press @ref fails when Android tap escapes to launcher', async () => { createdAt: Date.now(), backend: 'android', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockTapPoint.mockResolvedValue({ pressed: true }); mockGetAndroidAppState.mockResolvedValue({ @@ -148,7 +148,7 @@ test('press @ref fails when Android tap escapes to Settings', async () => { createdAt: Date.now(), backend: 'android', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockTapPoint.mockResolvedValue({ pressed: true }); mockGetAndroidAppState.mockResolvedValue({ @@ -203,7 +203,7 @@ test.each(ANDROID_PERMISSION_PROMPT_PACKAGES)( createdAt: Date.now(), backend: 'android', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockTapPoint.mockResolvedValue({ pressed: true }); mockGetAndroidAppState.mockResolvedValue({ @@ -268,7 +268,7 @@ test('a ref action aborts with the shared ref_frame_expired rejection after Andr }; session.snapshotGeneration = 900; // A freshly issued complete frame is active. - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await handleInteractionCommands({ req: { token: 't', session: sessionName, command: 'press', positionals: ['@e1'], flags: {} }, diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios-eligibility.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios-eligibility.test.ts index ec1e54d5ad..53be62df8a 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios-eligibility.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios-eligibility.test.ts @@ -66,7 +66,10 @@ beforeEach(() => { test('ordinary click uses canonical capture even when the owner binds selector tap', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-selector-without-direct-operation'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); mockCaptureSnapshotForSession.mockResolvedValueOnce({ nodes: attachRefs([ { @@ -111,7 +114,10 @@ test('ordinary click uses canonical capture even when the owner binds selector t test('fill simple iOS id selector resolves runtime text input evidence before coordinate fill', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-runtime-selector-fill'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); mockCaptureSnapshotForSession.mockResolvedValueOnce({ nodes: attachRefs([ @@ -175,7 +181,10 @@ test('fill simple iOS id selector resolves runtime text input evidence before co test('click simple iOS selector forwards Maestro non-hittable coordinate fallback', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-maestro-selector-fallback'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); mockTapElementSelector.mockResolvedValue({ message: 'tapped via non-hittable coordinate fallback', @@ -223,7 +232,7 @@ test('click simple iOS id selector waits for snapshot path after pending gesture const sessionName = 'ios-direct-selector-after-swipe'; const session = makeIosSession(sessionName, { appBundleId: 'com.example.app' }); session.postGestureStabilization = { action: 'swipe', positionals: [], markedAt: Date.now() }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockCaptureSnapshotForSession.mockResolvedValue({ nodes: attachRefs([ diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios.test.ts index c1948270a4..b001eac25d 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-direct-ios.test.ts @@ -52,7 +52,10 @@ test.each([ async (code, message) => { const sessionStore = makeSessionStore(); const sessionName = `ios-maestro-direct-selector-${code}`; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); mockTapElementSelector.mockRejectedValue(new AppError(code, message)); const response = await handleInteractionCommands({ @@ -77,7 +80,7 @@ test.each([ test('Maestro selector click crosses the ADR 0014 fused seam and expires the ref frame', async () => { const sessionStore = makeSessionStore(); const sessionName = 'maestro-direct-ios-seam'; - sessionStore.set(sessionName, makeStaleRefSession(sessionName)); + sessionStore.publish(sessionName, makeStaleRefSession(sessionName)); const click = await runInteraction(sessionStore, sessionName, 'click', ['label=Continue'], { maestro: { allowNonHittableCoordinateFallback: true }, @@ -121,7 +124,7 @@ for (const row of ROWS) { assert.equal(typeof row.fallsBack, 'boolean', `${row.id} must state fallsBack`); const sessionStore = makeSessionStore(); const sessionName = `maestro-direct-${row.id}`; - sessionStore.set(sessionName, makeStaleRefSession(sessionName)); + sessionStore.publish(sessionName, makeStaleRefSession(sessionName)); mockTapElementSelector.mockRejectedValueOnce(failure()); vi.mocked(captureSnapshotWithInteractor).mockResolvedValue({ nodes: makeTwoButtonNodes(), diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-fill.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-fill.test.ts index be1a3b3c49..905028089a 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-fill.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-fill.test.ts @@ -96,7 +96,7 @@ test('fill @ref fails fast when the target is off-screen', async () => { createdAt: Date.now(), backend: 'xctest', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await withRunner(() => handleInteractionCommands({ @@ -153,7 +153,7 @@ test('fill @ref fails closed when stored ref bounds are invalid (ADR 0014)', asy createdAt: Date.now(), backend: 'android', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockFillPoint.mockRejectedValue( new Error('dispatch must not run: no positional recapture on unusable frame evidence'), @@ -204,7 +204,7 @@ test('fill @ref rejects after a device action expired the frame', async () => { }; // ADR 0014: an unobserved device action expired the frame. expireRefFrame(session); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockFillPoint.mockRejectedValue( new Error('dispatch should not be called for an expired-frame ref'), ); @@ -244,7 +244,7 @@ test('fill with a pinned stale ref rejects; pinned current is clean', async () = session.snapshotGeneration = 3; // Re-issue the frame over the overridden snapshot so its source tree matches. activateCompleteRefFrame(session); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const stale = await runInteraction(sessionStore, sessionName, 'fill', ['@e1~s2', 'hello']); expect(stale?.ok).toBe(false); @@ -290,7 +290,7 @@ test("ADR 0014 blocker-2: a mutating find's internal fill from an expired frame // device side effect changed the screen. activateCompleteRefFrame(session); expireRefFrame(session); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockFillPoint.mockResolvedValue({ filled: true }); // Contrast: a user-supplied `@ref` against the expired frame rejects before @@ -355,7 +355,7 @@ test('fill @ref keeps the original editable node when its parent is the hittable createdAt: Date.now(), backend: 'xctest', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockFillPoint.mockResolvedValue({ filled: true }); diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-payload.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-payload.test.ts index d19e1bd7fe..3d424ce2d6 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-payload.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-payload.test.ts @@ -88,7 +88,7 @@ test('press coordinates appends touch-visualization events while recording', asy startedAt: Date.now() - 1_000, showTouches: true, }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockTapPoint.mockResolvedValue({ ok: true, @@ -139,7 +139,7 @@ test('press coordinates on iOS recording captures a full snapshot for the touch startedAt: Date.now() - 1_000, showTouches: true, }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockTapPoint.mockResolvedValue({ x: 220, y: 600 }); // Regression: a filtered snapshot has no Application/Window node, so viewport inference would @@ -198,7 +198,7 @@ test('press coordinates on Android recording uses physical screen size when no s showTouches: true, }); session.snapshot = undefined; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockTapPoint.mockResolvedValue({ x: 300, y: 2300 }); @@ -234,7 +234,7 @@ test('press coordinates on Android recording caches physical screen size across showTouches: true, }); session.snapshot = undefined; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockTapPoint.mockResolvedValue({ x: 300, y: 2300 }); @@ -281,7 +281,7 @@ test('press coordinates without recording skips Android screen-size lookup', asy const sessionName = 'android-direct-press-no-recording'; const session = makeAndroidSession(sessionName); session.snapshot = undefined; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockTapPoint.mockResolvedValue({ x: 300, y: 2300 }); @@ -314,7 +314,7 @@ test('press coordinates during recording still dispatches when Android screen-si showTouches: true, }); session.snapshot = undefined; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockTapPoint.mockResolvedValue({ x: 300, y: 2300 }); mockGetAndroidScreenSize.mockRejectedValue(new Error('adb unavailable')); @@ -368,7 +368,7 @@ test('press @ref preserves native timing in recorded result and touch visualizat startedAt: 1_000, showTouches: true, }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const originalNow = Date.now; let now = 1_500; diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-press-admission.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-press-admission.test.ts index ea0c48158c..9a855d8abb 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-press-admission.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-press-admission.test.ts @@ -85,7 +85,7 @@ test('click --button middle on macOS fails with an explicit unsupported-operatio kind: 'device', booted: true, }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockTapPoint.mockRejectedValue( new Error('dispatch should not be called for unsupported middle click'), @@ -116,7 +116,7 @@ test('click --button middle on macOS fails with an explicit unsupported-operatio test('press coordinates does not treat extra trailing args as selector', async () => { const sessionStore = makeSessionStore(); const sessionName = 'default'; - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); const response = await handleInteractionCommands({ req: { @@ -141,7 +141,7 @@ test('press coordinates does not treat extra trailing args as selector', async ( test('#1654: the resolved-target payload fails closed when its ref provenance disagrees', async () => { const sessionStore = makeSessionStore(); const sessionName = 'find-preresolved-mismatched-ref'; - sessionStore.set(sessionName, makeStaleRefSession(sessionName)); + sessionStore.publish(sessionName, makeStaleRefSession(sessionName)); const preresolved = makeFindPreresolvedTree(); const response = await runFindInternalClick(sessionStore, sessionName, { @@ -160,7 +160,7 @@ test('press selector then press @ref rejects refs that outlived the stored snaps const sessionStore = makeSessionStore(); const sessionName = 'stale-ref-warns'; const session = makeStaleRefSession(sessionName); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockCaptureSnapshotForSession.mockResolvedValue({ nodes: makeTwoButtonNodes(), backend: 'xctest', @@ -196,7 +196,7 @@ test('a ref press crosses the ADR 0014 side-effect seam and expires the ref fram const sessionStore = makeSessionStore(); const sessionName = 'seam-expiry'; const session = makeStaleRefSession(sessionName); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockCaptureSnapshotForSession.mockResolvedValue({ nodes: makeTwoButtonNodes(), backend: 'xctest', @@ -219,7 +219,7 @@ test('ADR 0014 evidence #1: a second ref mutation rejects (bare and pinned) unti session.snapshotGeneration = 500; // A complete snapshot issued the frame at generation 500. activateCompleteRefFrame(session); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockCaptureSnapshotForSession.mockResolvedValue({ nodes: makeTwoButtonNodes(), backend: 'xctest', @@ -251,7 +251,7 @@ test('press @ref directly after refs were issued does not warn', async () => { const sessionStore = makeSessionStore(); const sessionName = 'fresh-ref-no-warning'; const session = makeStaleRefSession(sessionName); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await runInteraction(sessionStore, sessionName, 'press', ['@e1']); expect(response?.ok).toBe(true); @@ -264,7 +264,7 @@ test('re-issuing a complete frame lets press @ref succeed again without warning' const sessionStore = makeSessionStore(); const sessionName = 'reissued-refs-no-warning'; const session = makeStaleRefSession(sessionName); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockCaptureSnapshotForSession.mockResolvedValue({ nodes: makeTwoButtonNodes(), backend: 'xctest', @@ -283,7 +283,6 @@ test('re-issuing a complete frame lets press @ref succeed again without warning' // snapshot-handler tests). Without it the frame would stay expired. const stored = sessionStore.get(sessionName)!; activateCompleteRefFrame(stored); - sessionStore.set(sessionName, stored); const refPress = await runInteraction(sessionStore, sessionName, 'press', ['@e1']); expect(refPress?.ok).toBe(true); @@ -299,7 +298,7 @@ test('press with a pinned ref matching the current frame epoch is clean', async const sessionName = 'pinned-current-clean'; const session = makeStaleRefSession(sessionName); session.snapshotGeneration = 5; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await runInteraction(sessionStore, sessionName, 'press', ['@e1~s5']); expect(response?.ok).toBe(true); @@ -313,7 +312,7 @@ test('press with a pinned ref from an older generation rejects with the precise const sessionName = 'pinned-stale-precise'; const session = makeStaleRefSession(sessionName); session.snapshotGeneration = 15; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockTapPoint.mockRejectedValue(new Error('touch should not be called for a stale iOS ref')); const response = await runInteraction(sessionStore, sessionName, 'press', ['@e1~s12']); @@ -343,7 +342,7 @@ test('ADR 0014 evidence #6: a read-only capture does not invalidate a mutation r backend: 'xctest', }); expect(refFrameState(session)).toBe('active'); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockTapPoint.mockResolvedValue({ pressed: true }); const response = await runInteraction(sessionStore, sessionName, 'press', ['@e1']); @@ -362,7 +361,7 @@ test('a malformed generation suffix is INVALID_ARGS with the ref grammar hint', const sessionStore = makeSessionStore(); const sessionName = 'pinned-malformed'; const session = makeStaleRefSession(sessionName); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); for (const [command, positionals] of [ ['press', ['@e1~x3']], @@ -392,7 +391,7 @@ test('after a session reopen, a pin from the previous lifetime rejects (reseeded // one replacement deep (a per-lifetime count from 1 would collide here). const reopened = makeStaleRefSession(sessionName); setSessionSnapshot(reopened, { ...reopened.snapshot! }); - sessionStore.set(sessionName, reopened); + sessionStore.publish(sessionName, reopened); // Probabilistic (~1/900000 collision) — accepted residual risk. expect(reopened.snapshotGeneration).not.toBe(oldGeneration); mockTapPoint.mockRejectedValue(new Error('touch should not be called for a stale iOS ref')); diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-press.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-press.test.ts index be2046aeda..01ef13c6f2 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-press.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-press.test.ts @@ -107,7 +107,7 @@ test('click on a macOS menubar wrapper ref promotes to the same-rect menu bar it createdAt: Date.now(), backend: 'macos-helper', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await handleInteractionCommands({ req: { @@ -164,7 +164,7 @@ test('press @ref promotes a non-hittable node to its hittable ancestor before ta createdAt: Date.now(), backend: 'xctest', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockTapPoint.mockResolvedValue({ pressed: true }); @@ -226,7 +226,7 @@ test('press @ref does not promote to a full-screen hittable ancestor', async () createdAt: Date.now(), backend: 'xctest', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockTapPoint.mockResolvedValue({ pressed: true }); @@ -285,7 +285,7 @@ test('click --button secondary on @ref dispatches a secondary press on macOS and createdAt: Date.now(), backend: 'xctest', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockTapPoint.mockResolvedValue({ button: 'secondary' }); @@ -321,7 +321,7 @@ test('click --button secondary on @ref dispatches a secondary press on macOS and test('#1654: a mutating find click acts on the node find resolved, not a re-resolved @ref', async () => { const sessionStore = makeSessionStore(); const sessionName = 'find-preresolved-click'; - sessionStore.set(sessionName, makeStaleRefSession(sessionName)); + sessionStore.publish(sessionName, makeStaleRefSession(sessionName)); const preresolved = makeFindPreresolvedTree(); const response = await runFindInternalClick(sessionStore, sessionName, { @@ -337,7 +337,7 @@ test('#1654: a mutating find click acts on the node find resolved, not a re-reso test('#1654 control: without the resolved-target payload the same click still resolves @ref from the session tree', async () => { const sessionStore = makeSessionStore(); const sessionName = 'find-preresolved-control'; - sessionStore.set(sessionName, makeStaleRefSession(sessionName)); + sessionStore.publish(sessionName, makeStaleRefSession(sessionName)); const response = await runFindInternalClick(sessionStore, sessionName, {}); @@ -355,7 +355,7 @@ test('#1654: the leaf performs no ref lookup at all — a session that could not const sessionName = 'find-preresolved-no-session-tree'; const session = makeSession(sessionName); session.snapshot = undefined; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const preresolved = makeFindPreresolvedTree(); const withoutPreresolution = await runFindInternalClick(sessionStore, sessionName, {}); @@ -374,7 +374,7 @@ test('#1654: the shared guards still run on the pre-resolved node', async () => // node is still refused, at the same ADR 0011 `runtime-ref` occlusion cell. const sessionStore = makeSessionStore(); const sessionName = 'find-preresolved-occluded'; - sessionStore.set(sessionName, makeStaleRefSession(sessionName)); + sessionStore.publish(sessionName, makeStaleRefSession(sessionName)); const preresolved = makeFindPreresolvedTree(); const blocked = { ...preresolved.node, @@ -396,7 +396,10 @@ test('#1654: the shared guards still run on the pre-resolved node', async () => test('a read right after a press captures the post-tap screen instead of reusing the pre-tap tree', async () => { const sessionStore = makeSessionStore(); const sessionName = 'read-after-press'; - sessionStore.set(sessionName, makeIosSession(sessionName, { appBundleId: 'com.example.app' })); + sessionStore.publish( + sessionName, + makeIosSession(sessionName, { appBundleId: 'com.example.app' }), + ); const screen = (step: string) => ({ backend: 'xctest' as const, producer: 'apple-runner' as const, diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-response.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-response.test.ts index 0dbc8a9b27..b941c43661 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-response.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-response.test.ts @@ -273,7 +273,7 @@ test('press @ref --verify surfaces evidence through the interactionResultExtra a createdAt: Date.now(), backend: 'xctest', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); // Post-action capture reports an extra node, so changedFromBefore should // read true against the pre-action (stored) snapshot's single node. @@ -355,7 +355,7 @@ test('press @ref without --verify never includes an evidence field', async () => createdAt: Date.now(), backend: 'xctest', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockTapPoint.mockResolvedValue({ pressed: true }); const response = await handleInteractionCommands({ @@ -399,7 +399,7 @@ test('fill selector --verify surfaces evidence through the interactionResultExtr createdAt: Date.now(), backend: 'xctest', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockCaptureSnapshotForSession.mockResolvedValue({ nodes: [ @@ -460,7 +460,7 @@ test('fill @ref --verify surfaces evidence in the ref response branch', async () createdAt: Date.now(), backend: 'xctest', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockCaptureSnapshotForSession.mockResolvedValue({ nodes: [ @@ -529,7 +529,7 @@ test('fill @ref without --verify never includes an evidence field', async () => createdAt: Date.now(), backend: 'xctest', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await handleInteractionCommands({ req: { @@ -578,7 +578,7 @@ test('fill @ref preserves fallback coordinates for recording when platform resul startedAt: Date.now() - 1_000, showTouches: true, }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockFillPoint.mockResolvedValue({ filled: true }); const response = await handleInteractionCommands({ diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-runtime.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-runtime.test.ts index 6368761b3f..fda5a2b5da 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch-runtime.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-runtime.test.ts @@ -82,7 +82,7 @@ test('press @ref taps the resolved point once and records the ref', async () => }, ]); session.snapshot = { nodes, createdAt: Date.now(), backend: 'xctest' }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await handleInteractionCommands({ req: { @@ -123,7 +123,7 @@ test('press @ref fails closed when the authorized ref has no usable bounds (ADR createdAt: Date.now(), backend: 'xctest', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockTapPoint.mockRejectedValue( new Error('dispatch must not run: no positional recapture on missing frame evidence'), @@ -180,7 +180,7 @@ test('press @ref fails closed when stored ref bounds are invalid (ADR 0014)', as createdAt: Date.now(), backend: 'android', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockTapPoint.mockRejectedValue( new Error('dispatch must not run: no positional recapture on unusable frame evidence'), @@ -236,7 +236,7 @@ test('press @ref fails fast when the target is off-screen', async () => { createdAt: Date.now(), backend: 'xctest', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await withRunner(() => handleInteractionCommands({ @@ -307,7 +307,7 @@ test('press @ref with a trailing label recovers within the authorized frame (no createdAt: Date.now(), backend: 'android', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockCaptureSnapshotForSession.mockRejectedValue( new Error('no positional recapture: recovery stays in-frame'), diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch.test.ts index 9fec76b04e..ac5e6465bf 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-touch.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-touch.test.ts @@ -74,7 +74,7 @@ test('press coordinates dispatches press and records as press', async () => { const sessionStore = makeSessionStore(); const sessionName = 'default'; const storedSession = makeSession(sessionName); - sessionStore.set(sessionName, storedSession); + sessionStore.publish(sessionName, storedSession); const response = await handleInteractionCommands({ req: { @@ -116,7 +116,7 @@ test.each([ const sessionName = `single-bind-${command}`; const session = makeSession(sessionName); if (web) session.device = WEB_DESKTOP_DEVICE; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await handleInteractionCommands({ req: { token: 't', session: sessionName, command, positionals: [...positionals], flags: {} }, @@ -134,7 +134,7 @@ test.each([ test('click rejects macOS desktop surface interactions until helper routing exists', async () => { const sessionStore = makeSessionStore(); const sessionName = 'macos-desktop-click'; - sessionStore.set(sessionName, makeMacOsDesktopSession(sessionName)); + sessionStore.publish(sessionName, makeMacOsDesktopSession(sessionName)); const response = await handleInteractionCommands({ req: { @@ -160,7 +160,7 @@ test('click rejects macOS desktop surface interactions until helper routing exis test('fill rejects macOS menubar surface interactions until helper routing exists', async () => { const sessionStore = makeSessionStore(); const sessionName = 'macos-menubar-fill'; - sessionStore.set(sessionName, makeMacOsMenubarSession(sessionName)); + sessionStore.publish(sessionName, makeMacOsMenubarSession(sessionName)); const response = await handleInteractionCommands({ req: { @@ -201,7 +201,7 @@ test('longpress @ref resolves the target and dispatches coordinate longpress', a createdAt: Date.now(), backend: 'xctest', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await handleInteractionCommands({ req: { @@ -247,7 +247,7 @@ test('hover @ref on web dispatches through the provider hoverRef route, not coor createdAt: Date.now(), backend: 'web', }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await handleInteractionCommands({ req: { token: 't', @@ -275,7 +275,7 @@ test('hover selector on web resolves the target and dispatches coordinate hover' const sessionName = 'hover-selector'; const session = makeSession(sessionName); session.device = WEB_DESKTOP_DEVICE; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockCaptureSnapshotForSession.mockResolvedValue({ nodes: attachRefs([ { @@ -315,7 +315,7 @@ test('hover selector on web resolves the target and dispatches coordinate hover' test('hover is refused by capability on touch platforms before any dispatch', async () => { const sessionStore = makeSessionStore(); const sessionName = 'hover-ios'; - sessionStore.set(sessionName, makeSession(sessionName)); + sessionStore.publish(sessionName, makeSession(sessionName)); const response = await handleInteractionCommands({ req: { diff --git a/src/daemon/interaction/internal/__tests__/interaction-type-android-readiness.test.ts b/src/daemon/interaction/internal/__tests__/interaction-type-android-readiness.test.ts index 9371210df3..35d40b2108 100644 --- a/src/daemon/interaction/internal/__tests__/interaction-type-android-readiness.test.ts +++ b/src/daemon/interaction/internal/__tests__/interaction-type-android-readiness.test.ts @@ -44,7 +44,7 @@ test('type continues and composes the recording readiness warning', async () => startedAt: 0, }); const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await handleInteractionCommands({ req: { diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-capabilities-install-projection.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-capabilities-install-projection.test.ts index 07dd92244b..8b64cca232 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-capabilities-install-projection.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-capabilities-install-projection.test.ts @@ -142,7 +142,7 @@ test('capabilities fixture preserves transport mode and owner-specific refusal r function createAndroidCapabilitiesSession(suffix: string) { const sessionName = `android-capabilities-${suffix}`; const sessionStore = makeSessionStore(`agent-device-capabilities-${suffix}-`); - sessionStore.set(sessionName, makeAndroidSession(sessionName)); + sessionStore.publish(sessionName, makeAndroidSession(sessionName)); return { sessionName, sessionStore }; } diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-capabilities.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-capabilities.test.ts index d6ff281cb3..c02e9d3e85 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-capabilities.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-capabilities.test.ts @@ -49,7 +49,7 @@ function assertAndroidCapabilityHonesty(availableCommands: unknown): void { /** The one interaction-capable Android owner both projection tests below read. */ async function projectAndroidCapabilities(sessionName: string) { const sessionStore = makeSessionStore('agent-device-capabilities-'); - sessionStore.set(sessionName, makeAndroidSession(sessionName)); + sessionStore.publish(sessionName, makeAndroidSession(sessionName)); const runtime = createAdmissionRuntime({ appLogAvailable: true, ensureReadyAvailable: true, @@ -121,7 +121,7 @@ test('capabilities omits the commands this Android owner does not admit', async test('capabilities excludes logs from an unavailable provider-mode XCTest runtime fact', async () => { const sessionName = 'provider-xctest-capabilities'; const sessionStore = makeSessionStore('agent-device-capabilities-provider-xctest-'); - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { name: sessionName, device: { platform: 'apple', @@ -176,7 +176,7 @@ test('capabilities excludes logs from an unavailable provider-mode XCTest runtim test('capabilities excludes network when the runtime fact is unavailable', async () => { const sessionName = 'android-capabilities-no-network'; const sessionStore = makeSessionStore('agent-device-capabilities-no-network-'); - sessionStore.set(sessionName, makeAndroidSession(sessionName)); + sessionStore.publish(sessionName, makeAndroidSession(sessionName)); const runtime = createAdmissionRuntime({ appLogAvailable: true, networkAvailable: false, @@ -217,7 +217,7 @@ test('capabilities includes apps for the available HarmonyOS runtime fact', asyn target: 'mobile', booted: true, } as const; - sessionStore.set(sessionName, makeSession(sessionName, { device: harmonyDevice })); + sessionStore.publish(sessionName, makeSession(sessionName, { device: harmonyDevice })); const runtime = createAdmissionRuntime({ appLogAvailable: true, networkAvailable: false, @@ -298,7 +298,7 @@ test.each(APPS_UNAVAILABLE_CAPABILITY_CASES)( async ({ label, device, providerMode }) => { const sessionName = `capabilities-${label.toLowerCase().replaceAll(' ', '-')}`; const sessionStore = makeSessionStore(`agent-device-capabilities-${label}-`); - sessionStore.set(sessionName, makeSession(sessionName, { device })); + sessionStore.publish(sessionName, makeSession(sessionName, { device })); const runtime = createAdmissionRuntime({ appLogAvailable: false, networkAvailable: false, @@ -332,7 +332,7 @@ test.each(APPS_UNAVAILABLE_CAPABILITY_CASES)( test('capabilities excludes appstate when its runtime fact is unavailable', async () => { const sessionName = 'android-capabilities-no-appstate'; const sessionStore = makeSessionStore('agent-device-capabilities-no-appstate-'); - sessionStore.set(sessionName, makeAndroidSession(sessionName)); + sessionStore.publish(sessionName, makeAndroidSession(sessionName)); const runtime = createAdmissionRuntime({ appLogAvailable: true, appStateAvailable: false, @@ -364,7 +364,7 @@ test('capabilities excludes appstate when its runtime fact is unavailable', asyn test('capabilities excludes appstate when its readiness fact is unavailable', async () => { const sessionName = 'android-capabilities-no-readiness'; const sessionStore = makeSessionStore('agent-device-capabilities-no-readiness-'); - sessionStore.set(sessionName, makeAndroidSession(sessionName)); + sessionStore.publish(sessionName, makeAndroidSession(sessionName)); const runtime = createAdmissionRuntime({ appLogAvailable: true, appStateAvailable: true, @@ -424,7 +424,7 @@ test.each([ async (_name, device) => { const sessionName = device.id + '-session'; const sessionStore = makeSessionStore('agent-device-capabilities-' + device.id + '-'); - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { name: sessionName, device, createdAt: Date.now(), diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-close-cwd-scoped-resources.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-close-cwd-scoped-resources.test.ts index 9918c3aa1d..002a466fc7 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-close-cwd-scoped-resources.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-close-cwd-scoped-resources.test.ts @@ -75,7 +75,7 @@ test('close finishes the recording of a cwd-scoped session by store address', as const sessionStore = makeSessionStore(); const session = makeIosSimulatorRecordingSession(sessionStore, ADDRESS, { name: NAME }); const finish = recordingFinishMock(session); - sessionStore.set(ADDRESS, session); + sessionStore.publish(ADDRESS, session); const resourcePath = screenRecordingResourceStore.resolvePath( sessionStore.resolveSessionDir(ADDRESS), ); @@ -86,7 +86,7 @@ test('close finishes the recording of a cwd-scoped session by store address', as device: { ...IOS_SIM, id: 'decoy-udid', name: 'iPhone 15' }, }); const decoyFinish = recordingFinishMock(decoy); - sessionStore.set(NAME, decoy); + sessionStore.publish(NAME, decoy); const decoyResourcePath = screenRecordingResourceStore.resolvePath( sessionStore.resolveSessionDir(NAME), ); @@ -143,7 +143,7 @@ test('close stops the app log of a cwd-scoped session by store address', async ( }, metadata: { phase: 'active' }, }); - sessionStore.set(ADDRESS, { + sessionStore.publish(ADDRESS, { ...makeSession(NAME, IOS_SIM), appBundleId: 'com.apple.Preferences', appLog: { handle, envelope }, @@ -215,7 +215,10 @@ test('close finishes the audio probe of a cwd-scoped session by store address', marker: { pid: 4242, startTime: 'boot+1', command: 'helper' }, }), }); - sessionStore.set(ADDRESS, { ...makeSession(NAME, IOS_SIM), audioProbe: { handle, envelope } }); + sessionStore.publish(ADDRESS, { + ...makeSession(NAME, IOS_SIM), + audioProbe: { handle, envelope }, + }); const resourcePath = audioProbeResourceStore.resolvePath(sessionDir); audioProbeResourceStore.write(resourcePath, envelope); @@ -255,7 +258,10 @@ test('close stops the perf capture of a cwd-scoped session by store address', as lifecycle: 'open', descriptor: { version: 1, body: { kind: 'fixture' } }, }); - sessionStore.set(ADDRESS, { ...makeSession(NAME, IOS_SIM), perfCapture: { handle, envelope } }); + sessionStore.publish(ADDRESS, { + ...makeSession(NAME, IOS_SIM), + perfCapture: { handle, envelope }, + }); const resourcePath = perfCaptureResourceStore.resolvePath(sessionDir); perfCaptureResourceStore.write(resourcePath, envelope); diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-close-error-precedence.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-close-error-precedence.test.ts index 3d2019eb61..ae89f86624 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-close-error-precedence.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-close-error-precedence.test.ts @@ -38,7 +38,7 @@ test('targeted close preserves the platform-close AppError and still runs later booted: true, }, }); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const platformCloseError = new AppError('DEVICE_UNAVAILABLE', 'platform close failed', { reason: 'device_disconnected', @@ -111,7 +111,7 @@ test('a failed platform close retains the device claim and reports it', async () } const session = makeIosSimulatorRecordingSession(sessionStore, sessionName, { device }); session.deviceClaim = acquired.ownership; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const platformCloseError = new AppError('DEVICE_UNAVAILABLE', 'platform close failed', { reason: 'device_disconnected', @@ -228,7 +228,7 @@ test('a failing best-effort cleanup also retains the device claim and reports it ? Envelope : never, }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); // The platform close itself succeeds; only the best-effort cleanup step fails, so the // blocking error arrives as the cleanup aggregate rather than as platformCloseError. @@ -321,7 +321,7 @@ test('a successful close clears the device claim', async () => { ...makeSession(sessionName, device), deviceClaim: acquired.ownership, }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await handleSessionCommands({ req: { @@ -362,7 +362,7 @@ test('targeted close skips platform dispatch and preserves the error when the re }), appBundleId: 'com.example.app', } as unknown as SessionState; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const preCloseError = new AppError('RUNNER_UNAVAILABLE', 'runner stop failed', { reason: 'runner_stop_failed', diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts index 05af3277fa..58f8e46dcc 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts @@ -89,7 +89,7 @@ test('an app-only close without a target fails before admission or ref-frame exp }; const session = makeSession(sessionName, device); activateCompleteRefFrame(session); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const revisionBeforeClose = readSessionRuntimeRevision(session); const response = await close({ @@ -120,7 +120,7 @@ test('close rejects a false close-target fact before its one implementation bind kind: 'emulator' as const, booted: true, }; - sessionStore.set(sessionName, makeSession(sessionName, device)); + sessionStore.publish(sessionName, makeSession(sessionName, device)); mockInspectDeviceRuntimeFacts.mockImplementationOnce(async (candidate) => { const facts = lifecycleRuntimeFacts(candidate); const unavailable = { @@ -163,7 +163,7 @@ test('a close-capable runtime with false runtime-hints facts never loads the run }; const session = makeSession(sessionName, device); session.appBundleId = 'com.example.harmony'; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockInspectDeviceRuntimeFacts.mockImplementationOnce(async (candidate) => { const facts = lifecycleRuntimeFacts(candidate); return { @@ -205,7 +205,7 @@ test('an app-only close with stored hints selects no runtime-hint cleanup siblin }; const session = makeSession(sessionName, device); session.appBundleId = 'com.example.app'; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); sessionStore.setRuntimeHints(sessionName, { platform: 'android', metroHost: '10.0.2.2' }); mockInspectDeviceRuntimeFacts.mockImplementationOnce(async (candidate) => { const facts = lifecycleRuntimeFacts(candidate); @@ -254,7 +254,7 @@ test('a close with persisted native hints fails closed when the distinct cleanup }; const session = makeSession(sessionName, device); session.appBundleId = 'com.example.app'; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); sessionStore.setRuntimeHints(sessionName, { platform: 'ios', metroHost: '10.0.2.2' }); mockInspectDeviceRuntimeFacts.mockImplementationOnce(async (candidate) => { const facts = lifecycleRuntimeFacts(candidate); @@ -296,7 +296,7 @@ test('a supported Android close clears admitted runtime hints exactly once', asy }; const session = makeSession(sessionName, device); session.appBundleId = 'com.example.app'; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); sessionStore.setRuntimeHints(sessionName, { platform: 'android', metroHost: '10.0.2.2', @@ -332,7 +332,7 @@ test('close expires the ref frame immediately before its admitted platform mutat }; const session = makeSession(sessionName, device); session.appBundleId = 'com.example.app'; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockDispatch.mockImplementationOnce(async () => { expect(refFrameState(session)).toBe('expired'); }); 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 41f5193bdf..d366a41a62 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-close-resource-cleanup.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-close-resource-cleanup.test.ts @@ -39,7 +39,7 @@ test('close stops Android snapshot helper session before deleting session', asyn kind: 'emulator', booted: true, }; - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, device), appBundleId: 'com.example.app', }); @@ -133,7 +133,7 @@ test('close stops active host audio probe before deleting session', async () => }), audioProbe: { handle, envelope }, }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); audioProbeResourceStore.write( audioProbeResourceStore.resolvePath(sessionStore.resolveSessionDir(sessionName)), envelope, @@ -161,7 +161,7 @@ test('close stops active host audio probe before deleting session', async () => test('close dispatches web session cleanup without a positional target', async () => { const sessionStore = makeSessionStore(); const sessionName = 'web-close-session'; - sessionStore.set(sessionName, makeSession(sessionName, WEB_DESKTOP_DEVICE)); + sessionStore.publish(sessionName, makeSession(sessionName, WEB_DESKTOP_DEVICE)); const response = await handleSessionCommands({ req: { @@ -199,7 +199,7 @@ test('close preserves the session and lease when provider release fails so it ca deviceKey: 'ios:bs-device', clientId: 'client-a', }); - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, WEB_DESKTOP_DEVICE), lease: { leaseId: lease.leaseId, diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-close-save-script.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-close-save-script.test.ts index 6c4c5a933a..68d45ca903 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-close-save-script.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-close-save-script.test.ts @@ -32,7 +32,7 @@ test('close --save-script on a never-armed session is rejected before teardown, // teardown to observably touch. const session = makeIosSimulatorRecordingSession(sessionStore, sessionName); const finish = recordingFinishMock(session); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); await expect( handleSessionCommands({ @@ -106,7 +106,7 @@ test('close --save-script on a session with an active .ad repair transaction is boundary: 0, }, }; - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await handleSessionCommands({ req: { diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-close-shutdown-platform.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-close-shutdown-platform.test.ts index ae4e4ec1fa..63cdbda0e4 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-close-shutdown-platform.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-close-shutdown-platform.test.ts @@ -52,7 +52,7 @@ function expectShutdownFailure( test('close --shutdown calls shutdownSimulator for iOS simulator and includes result in response', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-shutdown-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { platform: 'apple', @@ -111,7 +111,7 @@ test('close --shutdown keeps a selected provider-owned iOS simulator off local s kind: 'simulator' as const, booted: true, }; - sessionStore.set(sessionName, makeSession(sessionName, device)); + sessionStore.publish(sessionName, makeSession(sessionName, device)); const providerClose = vi.fn(async () => undefined); const providerFinalize = vi.fn(async () => ({})); mockInspectDeviceRuntimeFacts.mockImplementationOnce(async (candidate) => { @@ -159,7 +159,7 @@ test('close --shutdown keeps a selected provider-owned iOS simulator off local s test('close --shutdown calls shutdownAndroidEmulator for Android emulator and includes result in response', async () => { const sessionStore = makeSessionStore(); const sessionName = 'android-shutdown-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { platform: 'android', @@ -209,7 +209,7 @@ test('close --shutdown calls shutdownAndroidEmulator for Android emulator and in test('close --shutdown is ignored for non-simulator iOS devices', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-device-shutdown-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { platform: 'apple', @@ -247,7 +247,7 @@ test('close --shutdown is ignored for non-simulator iOS devices', async () => { test('close --shutdown is ignored for Android devices', async () => { const sessionStore = makeSessionStore(); const sessionName = 'android-device-shutdown-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { platform: 'android', @@ -285,7 +285,7 @@ test('close --shutdown is ignored for Android devices', async () => { test('close --shutdown returns success and failure payload when shutdownAndroidEmulator throws', async () => { const sessionStore = makeSessionStore(); const sessionName = 'android-shutdown-failure-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { platform: 'android', @@ -325,7 +325,7 @@ test('close --shutdown returns success and failure payload when shutdownAndroidE test('close --shutdown returns success and failure payload when shutdownSimulator throws', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-shutdown-failure-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { platform: 'apple', diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-devices-batch-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-devices-batch-runtime.test.ts index bbf3f549e7..52563445af 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-devices-batch-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-devices-batch-runtime.test.ts @@ -358,7 +358,7 @@ test('batch step pins nested requests to the resolved session', async () => { test('close clears retained materialized install paths bound to the session', async () => { const sessionStore = makeSessionStore('agent-device-devices-batch-runtime-'); const sessionName = 'materialized-close-active'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-inventory-apps-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-inventory-apps-runtime.test.ts index 9e54b04cb9..81d4f960a5 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-inventory-apps-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-inventory-apps-runtime.test.ts @@ -104,7 +104,7 @@ beforeEach(() => { test('macOS apps consumes generic readiness and app inventory through one runtime bind', async () => { const sessionName = 'macos-apps'; const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, makeSession(sessionName, MACOS_DEVICE)); + sessionStore.publish(sessionName, makeSession(sessionName, MACOS_DEVICE)); const req: DaemonRequest = { token: 'test-token', session: sessionName, diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-inventory-harmonyos.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-inventory-harmonyos.test.ts index 9ef2745297..ad184e7641 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-inventory-harmonyos.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-inventory-harmonyos.test.ts @@ -98,7 +98,7 @@ beforeEach(() => { async function listApps(appsFilter: 'all' | 'user-installed'): Promise { const sessionName = 'harmony-apps'; const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, makeSession(sessionName, HARMONY_DEVICE)); + sessionStore.publish(sessionName, makeSession(sessionName, HARMONY_DEVICE)); const req: DaemonRequest = { token: 'test-token', session: sessionName, diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-inventory-scoped-paths.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-inventory-scoped-paths.test.ts index 824e0758b3..a6a2683972 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-inventory-scoped-paths.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-inventory-scoped-paths.test.ts @@ -27,7 +27,7 @@ function scopedSession(): SessionState { async function runSessionList(): Promise { const sessionStore = makeSessionStore('agent-device-inventory-scoped-'); - sessionStore.set(SCOPED_KEY, scopedSession()); + sessionStore.publish(SCOPED_KEY, scopedSession()); const req: DaemonRequest = { token: 't', session: 'default', @@ -54,30 +54,30 @@ test('session list returns the caller cwd and local named sessions without cross }; const callerScope = resolveImplicitSessionScope(req)!; const callerSessionAddress = `cwd:${callerScope.id}:default`; - sessionStore.set(callerSessionAddress, { + sessionStore.publish(callerSessionAddress, { ...scopedSession(), sessionScope: callerScope, }); - sessionStore.set('qa-cart-integrity', { + sessionStore.publish('qa-cart-integrity', { ...scopedSession(), name: 'qa-cart-integrity', sessionScope: { kind: 'named-local' }, }); - sessionStore.set('tenant-a:qa', { + sessionStore.publish('tenant-a:qa', { ...scopedSession(), name: 'tenant-a:qa', sessionScope: { kind: 'named-local' }, }); - sessionStore.set('default', { + sessionStore.publish('default', { ...scopedSession(), sessionScope: { kind: 'global-default' }, }); - sessionStore.set('cwd:other:default', { + sessionStore.publish('cwd:other:default', { ...scopedSession(), sessionScope: { kind: 'cwd', id: 'other' }, }); const tenantSessionAddress = tenantScopedSessionName('tenant-a', 'remote-recording'); - sessionStore.set(tenantSessionAddress, { + sessionStore.publish(tenantSessionAddress, { ...scopedSession(), name: tenantSessionAddress, sessionScope: { kind: 'tenant', id: 'tenant-a' }, @@ -103,14 +103,14 @@ test('session list returns only sessions owned by the requesting tenant', async const sessionStore = makeSessionStore('agent-device-inventory-tenant-'); for (const tenantId of ['tenant-a', 'tenant-b']) { const address = tenantScopedSessionName(tenantId, 'default'); - sessionStore.set(address, { + sessionStore.publish(address, { ...scopedSession(), name: address, sessionScope: { kind: 'tenant', id: tenantId }, }); } for (const address of ['qa-cart-integrity', 'tenant-a:qa']) { - sessionStore.set(address, { + sessionStore.publish(address, { ...scopedSession(), name: address, sessionScope: { kind: 'named-local' }, diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts index f5d2f37a62..8d05648d5d 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts @@ -186,7 +186,7 @@ test('open runtime payload clears stale applied transport hints before launch', metroHost: '10.0.0.10', metroPort: 8081, }); - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, makeAndroidEmulator()), appBundleId: 'com.example.demo', appName: 'Demo', diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-existing.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-existing.test.ts index 554d189172..e0d28abc70 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-existing.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-existing.test.ts @@ -20,7 +20,7 @@ import { mkdtempForTestSync } from '../../../../__tests__/test-utils/tmp-dir.ts' test('open web URL on iOS device session without active app falls back to Safari', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-device-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { platform: 'apple', @@ -62,7 +62,7 @@ test('open web URL on iOS device session without active app falls back to Safari test('open web URL on iOS simulator session without active app falls back to Safari', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-simulator-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { platform: 'apple', @@ -112,7 +112,7 @@ test('open web URL on iOS simulator session without active app falls back to Saf test('open app and URL on existing iOS device session keeps app context', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-device-session'; - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, { platform: 'apple', id: 'ios-device-1', @@ -159,7 +159,7 @@ test('open app and URL on existing iOS device session keeps app context', async test('open app on existing macOS session resolves and stores bundle id', async () => { const sessionStore = makeSessionStore(); const sessionName = 'macos-session'; - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, { platform: 'apple', appleOs: 'macos', @@ -234,7 +234,7 @@ test('open rejects --surface on non-macOS devices', async () => { test('open on existing macOS frontmost-app session preserves surface without --surface flag', async () => { const sessionStore = makeSessionStore(); const sessionName = 'macos-frontmost-existing'; - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, { platform: 'apple', appleOs: 'macos', @@ -295,7 +295,7 @@ test('open on existing macOS frontmost-app session preserves surface without --s test('open on existing iOS session refreshes unavailable simulator by name', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-session'; - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, { platform: 'apple', id: 'stale-sim', @@ -361,7 +361,7 @@ test('open on existing iOS session refreshes unavailable simulator by name', asy test('open app on existing Android session resolves and stores package id', async () => { const sessionStore = makeSessionStore(); const sessionName = 'android-session'; - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, { platform: 'android', id: 'emulator-5554', @@ -404,7 +404,7 @@ test('open app on existing Android session resolves and stores package id', asyn test('open intent target on existing Android session clears stale package context', async () => { const sessionStore = makeSessionStore(); const sessionName = 'android-session'; - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, { platform: 'android', id: 'emulator-5554', @@ -455,7 +455,7 @@ test('open on existing Android session preserves a comparable freshness baseline type: 'android.widget.TextView', label: `Inbox row ${index + 1}`, })); - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, { platform: 'android', id: 'emulator-5554', diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-url-prewarm.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-url-prewarm.test.ts index 96010f58fb..4a3aa559fc 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-url-prewarm.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-url-prewarm.test.ts @@ -137,7 +137,7 @@ function sessionRequest( test('open web URL on existing iOS simulator session binds Safari and drops stale app bundle id', async () => { const sessionStore = makeSessionStore('agent-device-session-open-url-prewarm-'); const sessionName = 'ios-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { @@ -179,7 +179,7 @@ test('open web URL on existing iOS simulator session binds Safari and drops stal test('open URL on existing macOS session clears stale app bundle id', async () => { const sessionStore = makeSessionStore('agent-device-session-open-url-prewarm-'); const sessionName = 'macos-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { @@ -216,7 +216,7 @@ test('open URL on existing macOS session clears stale app bundle id', async () = test('open URL on existing iOS device session preserves app bundle id context', async () => { const sessionStore = makeSessionStore('agent-device-session-open-url-prewarm-'); const sessionName = 'ios-device-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { @@ -251,7 +251,7 @@ test('open URL on existing iOS device session preserves app bundle id context', test('open custom URL on existing iOS simulator session preserves app bundle id context', async () => { const sessionStore = makeSessionStore('agent-device-session-open-url-prewarm-'); const sessionName = 'ios-simulator-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { @@ -364,7 +364,7 @@ test('open iOS simulator app prewarms runner cache during cold boot', async () = test('open iOS app session prewarms runner session when app bundle id is known', async () => { const sessionStore = makeSessionStore('agent-device-session-open-url-prewarm-'); const sessionName = 'ios-device-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { @@ -398,7 +398,7 @@ test('open iOS Maestro app link waits for runner prewarm before launching app', const sessionName = 'ios-maestro-open-link'; const events: string[] = []; let finishPrewarm: (() => void) | undefined; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { @@ -460,7 +460,7 @@ test('open iOS Maestro app link resets a simulator runner prewarmed before URL d kind: 'simulator' as const, booted: true, }; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device, @@ -499,7 +499,7 @@ test('open iOS Maestro app link resets a simulator runner prewarmed before URL d test('open iOS Maestro app link reports blocking runner prewarm failures before launching app', async () => { const sessionStore = makeSessionStore('agent-device-session-open-url-prewarm-'); const sessionName = 'ios-maestro-open-link-prewarm-failed'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { @@ -542,7 +542,7 @@ test('open iOS Maestro app link reports blocking runner prewarm failures before test('open iOS URL without app bundle id skips runner prewarm', async () => { const sessionStore = makeSessionStore('agent-device-session-open-url-prewarm-'); const sessionName = 'ios-device-session'; - sessionStore.set( + sessionStore.publish( sessionName, makeSession(sessionName, { device: { diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-teardown-resources.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-teardown-resources.test.ts index 3a90103ced..5c3f52f660 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-teardown-resources.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-teardown-resources.test.ts @@ -38,7 +38,7 @@ test('close finalizes an active iOS simulator recording before deleting the sess const sessionName = 'ios-active-recording-close-session'; const session = makeIosSimulatorRecordingSession(sessionStore, sessionName); const finish = recordingFinishMock(session); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); const response = await handleSessionCommands({ req: { @@ -75,7 +75,7 @@ test('close surfaces a recording finalization failure through the cleanup-failur }); const finish = recordingFinishMock(session); const forceCleanup = recordingCleanupMock(session); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); await expect( handleSessionCommands({ @@ -105,7 +105,7 @@ test('daemon resource teardown finalizes recording before lifecycle runner dispo const sessionStore = makeSessionStore(); const session = makeIosSimulatorRecordingSession(sessionStore, sessionName); const finish = recordingFinishMock(session); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); await teardownSessionResources({ appLog: 'already-settled', @@ -138,7 +138,7 @@ test('daemon session teardown surfaces a recording finalization failure', async }); const finish = recordingFinishMock(session); const forceCleanup = recordingCleanupMock(session); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); await expect( teardownSessionResources({ @@ -162,7 +162,7 @@ test('daemon session teardown retains recording evidence when finish and forced }); const finish = recordingFinishMock(session); const forceCleanup = recordingCleanupMock(session); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); await expect( teardownSessionResources({ @@ -229,7 +229,7 @@ test('daemon session teardown attempts every resource after an earlier cleanup r // Perf cleanup runs before the snapshot-helper cleanup. const sessionStore = makeSessionStore(); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); await expect( teardownSessionResources({ @@ -279,7 +279,7 @@ test('daemon session teardown closes an open web session immediately, not on age // Teardown always runs while the session it is tearing down is still in the store (session // deletion happens after), which is what keeps the provider-startup orphan sweep from treating // this session's own browser as an orphan and scanning real host processes for it. - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockRunCmd.mockResolvedValue(agentBrowserJsonResult({ success: true, data: {} })); await teardownSessionResources({ @@ -303,7 +303,7 @@ test('daemon session teardown surfaces a web close failure through the cleanup-f const sessionStore = makeSessionStore(); await installFakeManagedAgentBrowser(sessionStore.resolveDaemonStateDir()); const session = makeSession(sessionName, WEB_DESKTOP_DEVICE); - sessionStore.set(sessionName, session); + sessionStore.publish(sessionName, session); mockRunCmd.mockResolvedValue( agentBrowserJsonResult({ success: false, error: 'no active browser session' }), ); diff --git a/src/daemon/session-observability/internal/__tests__/session-audio.test.ts b/src/daemon/session-observability/internal/__tests__/session-audio.test.ts index 37699f875d..7cf3bc0032 100644 --- a/src/daemon/session-observability/internal/__tests__/session-audio.test.ts +++ b/src/daemon/session-observability/internal/__tests__/session-audio.test.ts @@ -200,7 +200,7 @@ function audioParams( test('audio probe validates daemon duration bounds', async () => { const sessionStore = makeSessionStore('agent-device-session-audio-'); - sessionStore.set('web', makeSession('web', { device: WEB_DESKTOP_DEVICE })); + sessionStore.publish('web', makeSession('web', { device: WEB_DESKTOP_DEVICE })); const { params, bindDevice } = audioParams('web', sessionStore, WEB_DESKTOP_DEVICE, { positionals: ['probe', 'start', '99', '1000'], cells: { capture: false, query: true }, @@ -215,7 +215,7 @@ test('audio probe validates daemon duration bounds', async () => { test('audio probe validates daemon bucket bounds', async () => { const sessionStore = makeSessionStore('agent-device-session-audio-'); - sessionStore.set('web', makeSession('web', { device: WEB_DESKTOP_DEVICE })); + sessionStore.publish('web', makeSession('web', { device: WEB_DESKTOP_DEVICE })); const { params, bindDevice } = audioParams('web', sessionStore, WEB_DESKTOP_DEVICE, { positionals: ['probe', 'start', '1000', '99'], cells: { capture: false, query: true }, @@ -230,7 +230,7 @@ test('audio probe validates daemon bucket bounds', async () => { test('audio probe rejects timing positionals for status', async () => { const sessionStore = makeSessionStore('agent-device-session-audio-'); - sessionStore.set('web', makeSession('web', { device: WEB_DESKTOP_DEVICE })); + sessionStore.publish('web', makeSession('web', { device: WEB_DESKTOP_DEVICE })); const { params, bindDevice } = audioParams('web', sessionStore, WEB_DESKTOP_DEVICE, { positionals: ['probe', 'status', '1000'], cells: { capture: false, query: true }, @@ -242,7 +242,7 @@ test('audio probe rejects timing positionals for status', async () => { test('audio refuses with the owner-stated hint when no fact admits it', async () => { const sessionStore = makeSessionStore('agent-device-session-audio-'); - sessionStore.set('ios-device', makeIosSession('ios-device', { device: IOS_DEVICE })); + sessionStore.publish('ios-device', makeIosSession('ios-device', { device: IOS_DEVICE })); const { params, bindDevice } = audioParams('ios-device', sessionStore, IOS_DEVICE, { positionals: ['probe', 'status'], cells: { capture: false, query: false }, @@ -260,7 +260,7 @@ test('audio refuses with the owner-stated hint when no fact admits it', async () test('audio probe start binds once, adopts the durable handle, and answers from it', async () => { const sessionStore = makeSessionStore('agent-device-session-audio-'); const session = makeMacOsSession('macos'); - sessionStore.set('macos', session); + sessionStore.publish('macos', session); const runtime = fakeCaptureRuntime(session.device, runningStatus()); const { params, bindDevice } = audioParams('macos', sessionStore, session.device, { positionals: ['probe', 'start', '1000', '500'], @@ -289,7 +289,7 @@ test('audio probe start binds once, adopts the durable handle, and answers from test('audio probe starts host helper for iOS simulator audio', async () => { const sessionStore = makeSessionStore('agent-device-session-audio-'); const session = makeIosSession('ios-sim'); - sessionStore.set('ios-sim', session); + sessionStore.publish('ios-sim', session); const runtime = fakeCaptureRuntime(session.device, runningStatus()); const { params } = audioParams('ios-sim', sessionStore, session.device, { positionals: ['probe', 'start', '1000', '500'], @@ -307,7 +307,7 @@ test('audio probe starts host helper for iOS simulator audio', async () => { test(ANDROID_AUDIO_CONTRACT_EVIDENCE.testName, async () => { const sessionStore = makeSessionStore('agent-device-session-audio-'); const session = makeAndroidSession('android'); - sessionStore.set('android', session); + sessionStore.publish('android', session); const runtime = fakeCaptureRuntime(session.device, runningStatus({ peakDbfs: [-13] })); const { params } = audioParams('android', sessionStore, session.device, { positionals: ['probe', 'start', '1000', '500'], @@ -325,7 +325,7 @@ test(ANDROID_AUDIO_CONTRACT_EVIDENCE.testName, async () => { test('audio probe stop finishes the durable resource and clears the slot', async () => { const sessionStore = makeSessionStore('agent-device-session-audio-'); const session = makeMacOsSession('macos'); - sessionStore.set('macos', session); + sessionStore.publish('macos', session); const runtime = fakeCaptureRuntime(session.device, runningStatus()); const start = audioParams('macos', sessionStore, session.device, { positionals: ['probe', 'start', '1000', '500'], @@ -355,7 +355,7 @@ test('audio probe stop finishes the durable resource and clears the slot', async test('audio probe status without an active probe reports not-started', async () => { const sessionStore = makeSessionStore('agent-device-session-audio-'); const session = makeMacOsSession('macos'); - sessionStore.set('macos', session); + sessionStore.publish('macos', session); const { params } = audioParams('macos', sessionStore, session.device, { positionals: ['probe', 'status'], cells: { capture: true, query: false }, @@ -370,7 +370,7 @@ test('audio probe status without an active probe reports not-started', async () test('audio probe forwards daemon millisecond timing to the web query operation', async () => { const sessionStore = makeSessionStore('agent-device-session-audio-'); - sessionStore.set('web', makeSession('web', { device: WEB_DESKTOP_DEVICE })); + sessionStore.publish('web', makeSession('web', { device: WEB_DESKTOP_DEVICE })); const audioProbeQuery = vi.fn( async (_input: { action: string; durationMs: number; bucketMs: number }) => runningStatus({ source: 'media-elements', backend: 'agent-browser' }), diff --git a/src/daemon/session-observability/internal/__tests__/session-logs.test.ts b/src/daemon/session-observability/internal/__tests__/session-logs.test.ts index f356a1c7a2..e3f426bac6 100644 --- a/src/daemon/session-observability/internal/__tests__/session-logs.test.ts +++ b/src/daemon/session-observability/internal/__tests__/session-logs.test.ts @@ -94,8 +94,7 @@ test('logs path binds only inspect and preserves public status projection', asyn test('logs doctor binds only doctor and merges live-state notes', async () => { const { sessionStore, sessionName } = openSession(); const session = sessionStore.get(sessionName)!; - sessionStore.set(sessionName, { - ...session, + sessionStore.update(sessionStore.lookup(sessionName)!, { appLog: makeTestAppLogResource(session, { backend: 'ios-simulator', state: 'ended', @@ -176,8 +175,7 @@ test.each([ '$action proves the app session before binding start operations', async ({ action, flags = {}, message }) => { const { sessionStore, sessionName } = openSession(); - const session = sessionStore.get(sessionName)!; - sessionStore.set(sessionName, { ...session, appBundleId: undefined }); + sessionStore.update(sessionStore.lookup(sessionName)!, { appBundleId: undefined }); const response = await runLogs( sessionStore, @@ -281,7 +279,7 @@ test('post-transfer SessionStore failure disposes the transferred handle and pre function openSession() { const sessionStore = makeSessionStore(); const sessionName = 'logs-session'; - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...makeSession(sessionName, DEVICE), appBundleId: 'com.example.app', }); diff --git a/src/daemon/session-observability/internal/__tests__/session-network-runtime-parity.test.ts b/src/daemon/session-observability/internal/__tests__/session-network-runtime-parity.test.ts index 7f07ef5112..78f56520df 100644 --- a/src/daemon/session-observability/internal/__tests__/session-network-runtime-parity.test.ts +++ b/src/daemon/session-observability/internal/__tests__/session-network-runtime-parity.test.ts @@ -24,7 +24,7 @@ test.each(NETWORK_RUNTIME_PROJECTION_PARITY)( kind: 'emulator', booted: true, }); - sessionStore.set(sessionName, { + sessionStore.publish(sessionName, { ...session, appBundleId: 'com.example.app', appLog: makeTestAppLogResource(session, { @@ -104,7 +104,7 @@ test('network admission remains fail-closed before parsing invalid input', async name: 'Harmony device', kind: 'device', }); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const runtime = createNetworkRuntime(session.device, async () => emptyAppLogResult('harmonyos'), { available: false, reason: 'unsupported-platform-leaf', @@ -147,7 +147,7 @@ test('an explicit web provider preserves empty-success projection', async () => kind: 'device', target: 'desktop', }); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const runtime = createNetworkRuntime( session.device, async () => ({ source: 'provider', backend: 'agent-browser', entries: [], notes: [] }), @@ -181,7 +181,7 @@ test.each(['browserstack', 'aws-device-farm', 'limrun'] as const)( name: `${provider} device`, kind: 'device', }); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const runtime = createNetworkRuntime( session.device, async () => emptyAppLogResult('android'), diff --git a/src/daemon/session-observability/internal/__tests__/session-network.test.ts b/src/daemon/session-observability/internal/__tests__/session-network.test.ts index 2c54450d96..1bb7d3a0b3 100644 --- a/src/daemon/session-observability/internal/__tests__/session-network.test.ts +++ b/src/daemon/session-observability/internal/__tests__/session-network.test.ts @@ -39,7 +39,7 @@ test('network validates the legacy limit after runtime-fact admission', async () name: 'Pixel', kind: 'emulator', }); - sessionStore.set(session.name, session); + sessionStore.publish(session.name, session); const runtime = createNetworkRuntime(session.device, async (input) => emptyAppLogResult('android', input), ); diff --git a/src/daemon/session-observability/internal/__tests__/session-observability.test.ts b/src/daemon/session-observability/internal/__tests__/session-observability.test.ts index a4164676e1..a3df8a5f50 100644 --- a/src/daemon/session-observability/internal/__tests__/session-observability.test.ts +++ b/src/daemon/session-observability/internal/__tests__/session-observability.test.ts @@ -104,6 +104,9 @@ test.each(REMOVED_AGGREGATE_PERF_POSITIONALS.map((positionals) => [positionals] function makeAndroidStore() { const sessionStore = makeSessionStore('agent-device-session-observability-'); - sessionStore.set('android', makeAndroidSession('android', { appBundleId: 'com.example.app' })); + sessionStore.publish( + 'android', + makeAndroidSession('android', { appBundleId: 'com.example.app' }), + ); return sessionStore; } diff --git a/src/daemon/session-observability/internal/__tests__/session-perf-runtime.test.ts b/src/daemon/session-observability/internal/__tests__/session-perf-runtime.test.ts index ed08e60858..365253e483 100644 --- a/src/daemon/session-observability/internal/__tests__/session-perf-runtime.test.ts +++ b/src/daemon/session-observability/internal/__tests__/session-perf-runtime.test.ts @@ -239,7 +239,10 @@ test.each(['shutdown', 'retire'] as const)( function makeStore() { const sessionStore = makeSessionStore('agent-device-perf-runtime-'); - sessionStore.set('android', makeAndroidSession('android', { appBundleId: 'com.example.app' })); + sessionStore.publish( + 'android', + makeAndroidSession('android', { appBundleId: 'com.example.app' }), + ); return sessionStore; } diff --git a/src/daemon/session-store.ts b/src/daemon/session-store.ts index 4cdc55e0de..3df053f544 100644 --- a/src/daemon/session-store.ts +++ b/src/daemon/session-store.ts @@ -60,11 +60,8 @@ export class SessionStore { } /** - * Returns the LIVE record, not a copy: mutating a field on the result is a durable write - * to store state whether or not {@link SessionStore.set} is called afterwards. Which - * modules may do that is declared in `SESSION_STATE_FIELD_OWNERS` - * (`scripts/layering/session-state.ts`) and enforced by the layering gate's R7, because - * nothing here can check the invariant a given field carries. + * Returns the live record. Field owners may mutate it through their transitions; + * record rebuilds use a lifetime-checked update. R7 enforces field ownership. */ get(name: string): SessionState | undefined { return this.sessions.get(name)?.current; @@ -143,27 +140,6 @@ export class SessionStore { return Object.freeze({ address, session: entry.current, lifetime: entry }); } - /** - * Insert or replace a session. Calling this with a record obtained from - * {@link SessionStore.get} is a no-op — the reference is already stored — so the call - * documents intent rather than committing anything; a genuinely new record needs it. - */ - set(name: string, session: SessionState): void { - // A key with no record is a NEW occupant, and the previous occupant's idle-expiry marker must - // stop explaining this key's absences from now on. Clearing it here rather than at `open` covers - // every way a record arrives — `open`'s provisional record, a record-only `record` session — and - // cannot be forgotten by a future insertion path. A replacing `open` on a live session takes the - // other branch and keeps whatever marker that session will earn for itself. - const entry = this.sessions.get(name); - if (entry) entry.current = session; - else this.publish(name, session); - } - - delete(name: string): boolean { - this.runtimeHints.delete(name); - return this.sessions.delete(name); - } - *values(): IterableIterator { for (const entry of this.sessions.values()) yield entry.current; } diff --git a/test/integration/nightly/concurrency-torture/bindings.ts b/test/integration/nightly/concurrency-torture/bindings.ts index 06fc88d0b1..267aac83c9 100644 --- a/test/integration/nightly/concurrency-torture/bindings.ts +++ b/test/integration/nightly/concurrency-torture/bindings.ts @@ -21,6 +21,7 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import type { CommandFlags } from '@agent-device/contracts/command'; import type { DaemonRequest } from '../../../../src/daemon/daemon-request.ts'; +import type { SessionRef } from '../../../../src/daemon/session-state.ts'; import type { SessionStore } from '../../../../src/daemon/session-store.ts'; import { resolveRequestExecutionLockPlan } from '../../../../src/daemon/request-binding.ts'; import { shouldLockSessionExecution } from '../../../../src/daemon/daemon-command-registry.ts'; @@ -71,6 +72,7 @@ export type LeaseScope = { * reference can never be mistaken for a live one. */ export type SessionInstance = { + ref: SessionRef; instanceId: number; name: string; deviceId: string; diff --git a/test/integration/nightly/concurrency-torture/harness.ts b/test/integration/nightly/concurrency-torture/harness.ts index f300b586c9..6cabd1ab17 100644 --- a/test/integration/nightly/concurrency-torture/harness.ts +++ b/test/integration/nightly/concurrency-torture/harness.ts @@ -403,9 +403,10 @@ class TortureWorld { ownerStartTime: null, }, }; - this.sessionStore.set(name, state); + const ref = this.sessionStore.publish(name, state); const instanceId = this.nextInstanceId++; const instance: SessionInstance = { + ref, instanceId, name, deviceId: device.id, @@ -449,10 +450,7 @@ class TortureWorld { this.fail('no leaked leases', `release threw for ${instance.name}: ${error.message}`); } this.claims.clear(instance.claim); - const stored = this.sessionStore.get(instance.name); - if (stored?.lease?.leaseId === instance.lease.leaseId) { - this.sessionStore.delete(instance.name); - } + this.sessionStore.retire(instance.ref); instance.reaped = true; if (this.deviceOwner.get(instance.deviceId) === instance.instanceId) { this.deviceOwner.delete(instance.deviceId); diff --git a/test/integration/provider-scenarios/android-tv-remote.test.ts b/test/integration/provider-scenarios/android-tv-remote.test.ts index cb279667fb..bb9baccd7d 100644 --- a/test/integration/provider-scenarios/android-tv-remote.test.ts +++ b/test/integration/provider-scenarios/android-tv-remote.test.ts @@ -31,7 +31,7 @@ test('Provider-backed integration Android TV remote flow sends D-pad keyevents', deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID_TV], }), async (daemon) => { - daemon.setSession('default', { + daemon.publishSession('default', { name: 'default', device: PROVIDER_SCENARIO_ANDROID_TV, createdAt: Date.now(), diff --git a/test/integration/provider-scenarios/apple-app-log-runtime-provider.test.ts b/test/integration/provider-scenarios/apple-app-log-runtime-provider.test.ts index 61b8d33646..19365aabec 100644 --- a/test/integration/provider-scenarios/apple-app-log-runtime-provider.test.ts +++ b/test/integration/provider-scenarios/apple-app-log-runtime-provider.test.ts @@ -16,7 +16,7 @@ test('provider-inventory Apple logs doctor stays on the scoped Apple tool provid appleToolProvider: () => appleTool.provider, deviceInventoryProvider: async () => [PROVIDER_SCENARIO_IOS_SIMULATOR], }); - daemon.setSession('default', { + daemon.publishSession('default', { name: 'default', device: PROVIDER_SCENARIO_IOS_SIMULATOR, appBundleId: 'com.example.app', diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index 01159d16bb..34c024dba6 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -83,7 +83,7 @@ export type ProviderScenarioHarness = { token: string; session: (name?: string) => SessionState | undefined; sessionDir: (name?: string) => string; - setSession: (name: string, session: SessionState) => void; + publishSession: (name: string, session: SessionState) => void; close: () => Promise; }; @@ -229,7 +229,7 @@ export async function createProviderScenarioHarness( token: PROVIDER_SCENARIO_TOKEN, session: (name = 'default') => sessionStore.get(name), sessionDir: (name = 'default') => sessionStore.resolveSessionDir(name), - setSession: (name, session) => sessionStore.set(name, session), + publishSession: (name, session) => sessionStore.publish(name, session), close: async () => { await deviceRuntimeGateway.shutdown(); await removeProviderScenarioTempDir(sessionDir); diff --git a/test/integration/provider-scenarios/ios-fold.test.ts b/test/integration/provider-scenarios/ios-fold.test.ts index 6dc08a5000..186f1bb774 100644 --- a/test/integration/provider-scenarios/ios-fold.test.ts +++ b/test/integration/provider-scenarios/ios-fold.test.ts @@ -105,7 +105,7 @@ test('timed fold keyframes reach simulator HID through the public client and dae }, }), }); - daemon.setSession( + daemon.publishSession( 'default', makeIosAppSession('default', { device: PROVIDER_SCENARIO_IOS_SIMULATOR }), ); @@ -278,7 +278,7 @@ async function runFoldLedgerScenario(params: { deviceInventoryProvider: async () => [PROVIDER_SCENARIO_IOS_SIMULATOR], appleToolProvider: () => provider, }); - daemon.setSession( + daemon.publishSession( 'default', makeIosAppSession('default', { device: PROVIDER_SCENARIO_IOS_SIMULATOR }), ); diff --git a/test/integration/provider-scenarios/ios-wait-selector.test.ts b/test/integration/provider-scenarios/ios-wait-selector.test.ts index 0feac0c5e1..4894ce235b 100644 --- a/test/integration/provider-scenarios/ios-wait-selector.test.ts +++ b/test/integration/provider-scenarios/ios-wait-selector.test.ts @@ -54,7 +54,7 @@ test.each([ }, }), }); - daemon.setSession('default', { + daemon.publishSession('default', { ...makeIosAppSession('default'), device: PROVIDER_SCENARIO_IOS_SIMULATOR, }); diff --git a/test/integration/provider-scenarios/network-runtime-provider.test.ts b/test/integration/provider-scenarios/network-runtime-provider.test.ts index 3172f7d8e2..84f61db51c 100644 --- a/test/integration/provider-scenarios/network-runtime-provider.test.ts +++ b/test/integration/provider-scenarios/network-runtime-provider.test.ts @@ -20,7 +20,7 @@ test('iOS simulator network recovery stays on the request-scoped Apple tool prov appleToolProvider: () => appleTool.provider, deviceInventoryProvider: async () => [PROVIDER_SCENARIO_IOS_SIMULATOR], }); - daemon.setSession('default', { + daemon.publishSession('default', { name: 'default', device: PROVIDER_SCENARIO_IOS_SIMULATOR, appBundleId: 'com.example.app', diff --git a/test/integration/provider-scenarios/stale-provider-runtime-admission.fixtures.ts b/test/integration/provider-scenarios/stale-provider-runtime-admission.fixtures.ts index cf618d1f0b..8b6895ff1b 100644 --- a/test/integration/provider-scenarios/stale-provider-runtime-admission.fixtures.ts +++ b/test/integration/provider-scenarios/stale-provider-runtime-admission.fixtures.ts @@ -74,7 +74,7 @@ export async function inspectProviderDeploymentAdmission(params: { fs.writeFileSync(appPath, 'fixture'); try { - sessionStore.set('default', { + sessionStore.publish('default', { name: 'default', createdAt: 1, actions: [], diff --git a/test/integration/provider-scenarios/vega-tv-remote.test.ts b/test/integration/provider-scenarios/vega-tv-remote.test.ts index 0fdd4c4c6e..3ddad7f116 100644 --- a/test/integration/provider-scenarios/vega-tv-remote.test.ts +++ b/test/integration/provider-scenarios/vega-tv-remote.test.ts @@ -54,7 +54,7 @@ test('Provider-backed Vega TV flow routes lifecycle and every remote control as deviceInventoryProvider: async () => [VEGA_TV], }), async (daemon) => { - daemon.setSession('default', { + daemon.publishSession('default', { name: 'default', device: VEGA_TV, createdAt: Date.now(),