Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/__tests__/test-utils/store-factory.ts
Original file line number Diff line number Diff line change
@@ -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);
}
3 changes: 2 additions & 1 deletion src/daemon/__tests__/request-lock-identity-policy.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion src/daemon/__tests__/request-lock-policy.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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', () => {
Expand Down
3 changes: 2 additions & 1 deletion src/daemon/__tests__/session-selector.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -24,7 +25,7 @@ function makeSession(overrides?: Partial<SessionState>): 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', () => {
Expand Down
118 changes: 118 additions & 0 deletions src/daemon/__tests__/session-store-lifetime.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
1 change: 1 addition & 0 deletions src/daemon/server/daemon-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { makeStoredSessionRef } from '../../../../__tests__/test-utils/store-factory.ts';
import { test, expect } from 'vitest';
import {
buildDeviceInUseBySessionError,
Expand All @@ -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);
Expand All @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -56,8 +55,7 @@ export async function runSessionCloseTeardown(params: {
}): Promise<SessionCloseTeardownResult> {
const {
req,
session,
sessionName,
ref,
logPath,
sessionStore,
lifecycle,
Expand All @@ -67,6 +65,8 @@ export async function runSessionCloseTeardown(params: {
dispatchTargetedPlatformClose,
finalizeOrdinaryCloseScript,
} = params;
const { address: sessionName } = ref;
const session = sessionStore.requireCurrent(ref);
const attemptCleanup = async <Result>(
step: string,
run: () => Promise<Result>,
Expand All @@ -86,7 +86,7 @@ export async function runSessionCloseTeardown(params: {
});
const configuredRuntimeHints = sessionStore.getRuntimeHints(sessionName);
await stopBestEffortSessionResources(
{ address: sessionName, session },
ref,
sessionStore,
attemptCleanup,
params.platformResourceCleanup,
Expand Down
20 changes: 9 additions & 11 deletions src/daemon/session-lifecycle/internal/session-close.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -193,15 +193,16 @@ export async function handleSessionCloseCommands(
params: SessionCloseCommandInput,
): Promise<DaemonResponse> {
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,
inspectFacts: params.inspectFacts,
bindDevice: params.bindDevice,
});
}
const session = ref.session;
Comment thread
thymikee marked this conversation as resolved.
assertTerminalRecordingCloseAllowed(req, session);
const app = req.positionals?.[0];
if (req.internal?.closeAppOnly === true && !app) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -294,20 +293,19 @@ async function runCloseTeardownAndRelease(params: {
}): Promise<SessionCloseFinalization> {
const {
req,
session,
sessionName,
ref,
logPath,
sessionStore,
leaseRegistry,
leaseLifecycleProvider,
lifecycle,
clearRuntimeHints,
} = params;
const { address: sessionName, session } = ref;
const cleanupFailures: SessionCleanupFailure[] = [];
const { platformCloseError, saveScriptError, shutdownResult } = await runSessionCloseTeardown({
req,
session,
sessionName,
ref,
logPath,
sessionStore,
lifecycle,
Expand Down
19 changes: 7 additions & 12 deletions src/daemon/session-recovery-hints.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -12,17 +13,17 @@ import { IOS_SIMULATOR } from '../__tests__/test-utils/device-fixtures.ts';
const SCOPED_ADDRESS = 'cwd:8bea844ab16aa9b3:default';

function scopedRef(overrides: Partial<SessionState> = {}): SessionRef {
return {
address: SCOPED_ADDRESS,
session: {
return makeStoredSessionRef(
{
name: 'default',
sessionScope: { kind: 'cwd', id: '8bea844ab16aa9b3' },
device: IOS_SIMULATOR,
createdAt: Date.now(),
actions: [],
...overrides,
},
};
SCOPED_ADDRESS,
);
}

test('device-in-use recovery names the address --session accepts, not the public name', () => {
Expand Down Expand Up @@ -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',
);

Expand All @@ -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',
);

Expand Down
Loading
Loading