Skip to content
Merged
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 packages/contracts/src/application-lifecycle-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,11 @@ export type CloseApplicationInput = Readonly<{
surface: SessionSurface;
/** A selector-only close establishes readiness inside its admitted lifecycle binding. */
ensureReady?: boolean;
/**
* `kill` asks for system-initiated process death of the positional target (Maestro `killApp`);
* absent means an ordinary close. A backend without such a primitive closes normally.
*/
mode?: 'kill';
execution: ApplicationLifecycleExecution;
}>;

Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,8 @@ export type ReplayDispatchOptions = Readonly<{
openLifecycle?: ReplayOpenLifecycle;
/** Terminate the targeted app without ending the owning daemon session. */
closeAppOnly?: boolean;
/** With `closeAppOnly`: system-initiated process death (Maestro `killApp`) instead of a close. */
killApp?: boolean;
/**
* Daemon-composed hierarchy capture used as operational evidence only. It must not issue or
* replace client ref authority.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import path from 'node:path';
import { SessionStore } from '../../../../core/session-store.ts';
import type { DaemonRequest, DaemonResponse } from '../../../../core/daemon-request.ts';
import type { SessionState } from '../../../../core/session-state.ts';
import type { CloseApplicationInput } from '@agent-device/contracts/application-lifecycle-runtime';
import {
activateCompleteRefFrame,
readSessionRuntimeRevision,
Expand Down Expand Up @@ -53,6 +54,7 @@ async function close(params: {
sessionStore: SessionStore;
positionals?: string[];
internal?: DaemonRequest['internal'];
bindDevice?: Parameters<typeof handleSessionCommands>[0]['bindDevice'];
}) {
return await handleSessionCommands({
createCommandSurface: fakeCreateCommandSurface,
Expand All @@ -62,6 +64,7 @@ async function close(params: {
logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'),
sessionStore: params.sessionStore,
invoke: noopInvoke,
bindDevice: params.bindDevice,
});
}

Expand Down Expand Up @@ -172,6 +175,54 @@ test('close expires the ref frame immediately before its admitted platform mutat
expect(mockDispatch).toHaveBeenCalledOnce();
});

test('an app-only kill close carries mode kill while a stop close carries none', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'android-app-only-kill-carries-mode';
const device = {
platform: 'android' as const,
id: 'emulator-5554',
name: 'Pixel',
kind: 'emulator' as const,
target: 'mobile' as const,
booted: true,
};
const session = makeSession(sessionName, device);
session.appBundleId = 'com.example.app';
sessionStore.publish(sessionName, session);
const seenModes: Array<CloseApplicationInput['mode']> = [];
const bindDevice: Parameters<typeof close>[0]['bindDevice'] = async (boundDevice, use) => {
const binding = await mockBindDeviceRuntime(boundDevice, use);
return {
...binding,
operations: {
...binding.operations,
closeApplication: async (input: CloseApplicationInput) => {
seenModes.push(input.mode);
},
},
};
};

const killed = await close({
sessionName,
sessionStore,
positionals: ['com.example.app'],
internal: { closeAppOnly: true, killApp: true },
bindDevice,
});
const stopped = await close({
sessionName,
sessionStore,
positionals: ['com.example.app'],
internal: { closeAppOnly: true },
bindDevice,
});

expect(killed?.ok).toBe(true);
expect(stopped?.ok).toBe(true);
expect(seenModes).toEqual(['kill', undefined]);
});

test('app-only close expires the rebuilt record after admission, preserving the captured snapshot', async () => {
const sessionStore = makeSessionStore();
const address = 'cwd:close:default';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { daemonPlatformServicesFixture } from '../../../../__tests__/support/platform-services-fixture.ts';
import { test, expect, vi } from 'vitest';

import path from 'node:path';
import type { DeviceInfo } from '@agent-device/kernel/device';
import type { CloseApplicationInput } from '@agent-device/contracts/application-lifecycle-runtime';
import type { DaemonRequest, DaemonResponse } from '../../../../core/daemon-request.ts';
import { SessionStore } from '../../../../core/session-store.ts';
import { fakeCreateCommandSurface } from '../../../../__tests__/support/command-surface-fake.ts';
import { mkdtempForTestSync } from '@agent-device/test-kit/tmp-dir';

const mockResolveTargetDevice = vi.hoisted(() => vi.fn());

vi.mock('@agent-device/device-selection/dispatch-resolve', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@agent-device/device-selection/dispatch-resolve')>();
const { selectionFromResolveTargetDevice } =
await import('../../../../__tests__/support/device-selection-stub.ts');
return {
...actual,
resolveTargetDevice: mockResolveTargetDevice,
resolveTargetDeviceSelection: vi.fn(selectionFromResolveTargetDevice(mockResolveTargetDevice)),
};
});

import {
handleSessionCommands,
mockBindDeviceRuntime,
} from '../../../../__tests__/support/handlers/session-command-harness.ts';

const noopInvoke = async (_req: DaemonRequest): Promise<DaemonResponse> => ({ ok: true, data: {} });

const ANDROID_DEVICE: DeviceInfo = {
platform: 'android',
id: 'emulator-5554',
name: 'Pixel',
kind: 'emulator',
target: 'mobile',
booted: true,
};

test('an app-only kill close with no stored session carries mode kill to the platform close', async () => {
const sessionStore = new SessionStore(
path.join(mkdtempForTestSync('agent-device-session-close-without-session-'), 'sessions'),
);
mockResolveTargetDevice.mockResolvedValue(ANDROID_DEVICE);
const seenInputs: CloseApplicationInput[] = [];

const response = await handleSessionCommands({
createCommandSurface: fakeCreateCommandSurface,
platformServices: daemonPlatformServicesFixture(),
req: {
token: 't',
session: 'no-such-session',
command: 'close',
positionals: ['com.example.app'],
flags: { serial: 'emulator-5554' },
internal: { closeAppOnly: true, killApp: true },
},
sessionName: 'no-such-session',
logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'),
sessionStore,
invoke: noopInvoke,
bindDevice: async (device, use) => {
const binding = await mockBindDeviceRuntime(device, use);
return {
...binding,
operations: {
...binding.operations,
closeApplication: async (input: CloseApplicationInput) => {
seenInputs.push(input);
},
},
};
},
});

expect(response?.ok).toBe(true);
expect(seenInputs).toEqual([
expect.objectContaining({
mode: 'kill',
positionals: ['com.example.app'],
surface: 'app',
ensureReady: true,
}),
]);
});
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ async function dispatchTargetedPlatformClose(params: {
outPath: req.flags?.out,
appBundleId: session.appBundleId,
surface: session.surface ?? 'app',
...(req.internal?.killApp === true ? { mode: 'kill' as const } : {}),

@cubic-dev-ai cubic-dev-ai Bot Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: This only propagates kill through the session-backed dispatcher. A Maestro flow that starts before a daemon session exists enters closeWithoutSession, where the omitted mode makes Android perform stop instead of killApp; pass the same mode through that branch too.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/daemon/session-lifecycle/internal/session-close.ts, line 152:

<comment>This only propagates `kill` through the session-backed dispatcher. A Maestro flow that starts before a daemon session exists enters `closeWithoutSession`, where the omitted mode makes Android perform `stop` instead of `killApp`; pass the same mode through that branch too.</comment>

<file context>
@@ -149,6 +149,7 @@ async function dispatchTargetedPlatformClose(params: {
       outPath: req.flags?.out,
       appBundleId: session.appBundleId,
       surface: session.surface ?? 'app',
+      ...(req.internal?.killApp === true ? { mode: 'kill' as const } : {}),
       execution: applicationLifecycleExecutionFromRequest(req, logPath, session.trace?.outPath),
     });
</file context>
Fix with cubic

execution: applicationLifecycleExecutionFromRequest(req, logPath, session.trace?.outPath),
});
return undefined;
Expand Down Expand Up @@ -449,6 +450,7 @@ async function closeWithoutSession(params: {
outPath: req.flags?.out,
surface: 'app',
ensureReady: true,
...(req.internal?.killApp === true ? { mode: 'kill' as const } : {}),
execution: applicationLifecycleExecutionFromRequest(req, logPath),
});
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,47 @@ test('projects standalone clearState to settings without opening the app', async
]);
});

test('projects standalone killApp to an app-only close carrying the kill mode', async () => {
const requests: MaestroDaemonOperationRequest[] = [];
const invoke: MaestroDaemonOperationInvoke = async (request) => {
requests.push(request);
return { ok: true, data: {} };
};
const port = createDaemonMaestroRuntimePort({
...makeRuntimeEnvelope({ flags: { platform: 'android', replayBackend: 'maestro' } }),
invoke,
dependencies: makeDependencies(),
platform: 'android',
});

await port.execute({
command: { kind: 'killApp', source: { line: 2 }, appId: 'com.example.app' },
generation: 0,
env: {},
invalidateObservation() {},
});
await port.execute({
command: { kind: 'killApp', source: { line: 3 } },
generation: 1,
env: {},
appId: 'com.example.session',
invalidateObservation() {},
});

expect(requests).toEqual([
expect.objectContaining({
command: 'close',
positionals: ['com.example.app'],
dispatch: { closeAppOnly: true, killApp: true },
}),
expect.objectContaining({
command: 'close',
positionals: ['com.example.session'],
dispatch: { closeAppOnly: true, killApp: true },
}),
]);
});

test('uses the direct viewport without snapshot and pairs it with the nested gesture request', async () => {
const requests: MaestroDaemonOperationRequest[] = [];
const viewport = { x: 10, y: 20, width: 400, height: 800 };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,22 @@ describe('Maestro public operation projection', () => {
operation: { kind: 'stopApp' },
expected: { command: 'close', positionals: [], dispatch: { closeAppOnly: true } },
},
{
operation: { kind: 'killApp', appId: 'com.example' },
expected: {
command: 'close',
positionals: ['com.example'],
dispatch: { closeAppOnly: true, killApp: true },
},
},
{
operation: { kind: 'killApp' },
expected: {
command: 'close',
positionals: [],
dispatch: { closeAppOnly: true, killApp: true },
},
},
{
operation: { kind: 'clearState', appId: 'com.example' },
expected: { command: 'settings', positionals: ['clear-app-state', 'com.example'] },
Expand Down
4 changes: 4 additions & 0 deletions packages/maestro/src/daemon-port/daemon-runtime-port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,10 @@ function createDaemonMaestroRuntimeParts(options: CreateDaemonMaestroRuntimeOper
const appId = input.appId ?? context.appId;
await invokeMutation({ kind: 'stopApp', ...(appId ? { appId } : {}) }, context);
},
killApp: async (input, context) => {
const appId = input.appId ?? context.appId;
await invokeMutation({ kind: 'killApp', ...(appId ? { appId } : {}) }, context);
},
setPermissions: async (input, context) => {
await applyPermissionMutations(
input.appId ?? context.appId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export type MaestroPublicOperation =
launchArgs: string[];
}
| { kind: 'stopApp'; appId?: string }
| { kind: 'killApp'; appId?: string }
| { kind: 'clearState'; appId?: string }
| {
kind: 'settingsPermission';
Expand Down Expand Up @@ -53,6 +54,8 @@ export type MaestroPublicOperation =
export type MaestroDaemonDispatchOptions = Readonly<{
/** Terminate the targeted app without ending the owning daemon session. */
closeAppOnly?: true;
/** With `closeAppOnly`: Maestro `killApp` process death (`am kill` on Android) instead of `stop`. */
killApp?: true;
/** A hierarchy capture used as operational evidence only; it issues no client ref authority. */
observationOnly?: true;
/** Provider-owned viewport already resolved for a nested gesture command. */
Expand Down Expand Up @@ -88,12 +91,15 @@ export function projectMaestroPublicOperation(

type MaestroAppOperation = Extract<
MaestroPublicOperation,
{ kind: 'launchApp' | 'stopApp' | 'openLink' }
{ kind: 'launchApp' | 'stopApp' | 'killApp' | 'openLink' }
>;

function isAppOperation(operation: MaestroPublicOperation): operation is MaestroAppOperation {
return (
operation.kind === 'launchApp' || operation.kind === 'stopApp' || operation.kind === 'openLink'
operation.kind === 'launchApp' ||
operation.kind === 'stopApp' ||
operation.kind === 'killApp' ||
operation.kind === 'openLink'
);
}

Expand All @@ -103,6 +109,8 @@ function projectAppOperation(operation: MaestroAppOperation): MaestroDaemonOpera
return projectLaunchApp(operation);
case 'stopApp':
return projectStopApp(operation);
case 'killApp':
return projectKillApp(operation);
case 'openLink':
return projectOpenLink(operation);
}
Expand Down Expand Up @@ -132,6 +140,16 @@ function projectStopApp(
};
}

function projectKillApp(
operation: Extract<MaestroAppOperation, { kind: 'killApp' }>,
): MaestroDaemonOperationRequest {
return {
command: 'close',
positionals: operation.appId ? [operation.appId] : [],
dispatch: { closeAppOnly: true, killApp: true },
};
}

function projectClearState(
operation: Extract<MaestroPublicOperation, { kind: 'clearState' }>,
): MaestroDaemonOperationRequest {
Expand Down
21 changes: 21 additions & 0 deletions packages/maestro/src/internal/__tests__/program-ir-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,27 @@ describe('parseMaestroProgram', () => {
});
});

test('parses standalone killApp with an explicit or config app id', () => {
const program = parseMaestroProgram(
`appId: example.app
---
- killApp: example.app
- killApp
`,
{ sourcePath: '/flows/kill.yaml' },
);

assert.deepEqual(program.commands[0], {
kind: 'killApp',
source: { path: '/flows/kill.yaml', line: 3 },
appId: 'example.app',
});
assert.deepEqual(program.commands[1], {
kind: 'killApp',
source: { path: '/flows/kill.yaml', line: 4 },
});
});

test('preserves source paths for unsupported and malformed flows', () => {
const sourcePath = '/flows/includes/child.yaml';
assert.throws(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export function makeOperations(
resolveGestureViewport: async () => ({ x: 0, y: 0, width: 402, height: 874 }),
launchApp: noOp,
stopApp: noOp,
killApp: noOp,
setPermissions: noOp,
clearState: noOp,
openLink: noOp,
Expand Down
Loading
Loading