Skip to content

feat(maestro): support killApp via close mode - #2749

Merged
thymikee merged 1 commit into
callstack:mainfrom
Rohit3523:feat/maestro-killapp-impl
Oct 14, 2026
Merged

thymikee merged 1 commit into
callstack:mainfrom
Rohit3523:feat/maestro-killapp-impl

Conversation

@Rohit3523

@Rohit3523 Rohit3523 commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Supports Maestro killApp, which kills the app process the way the system would. On ADB-backed Android it runs am kill. iOS, Limrun and WebDriver-provider backends treat it as stopApp, and the docs and support matrix say so. There is no new public CLI surface.

  • The kill precondition reads the resumed activity from the ActivityManager (AMS), not window focus. A resumed target is refused with android-kill-requires-background-app. A process that survives the kill fails with android-kill-process-survived. If no activity dump answers, the kill is refused with android-process-probe-unavailable. If the dump answers but shows nothing resumed, the kill goes ahead.
  • The strict pidof check applies only to the kill's final check. Ordinary close, stopApp and relaunch keep main's behaviour.
  • The Android lifecycle loads the kill code lazily. upstream/116_kill_app is no longer listed as a divergence. The preconditions are recorded in DOCUMENTED_DEVIATIONS.
  • Rebased onto main as one commit, co-authored by Rohit. 30 files.

Validation

Tested commit 4661ed90c5.

  • pnpm check:affected --run: green (full fail-open set; 4856 unit tests).
  • pnpm maestro:conformance: 62/62 pass.
  • Android, Maestro, daemon-session and replay-port suites: 2701 tests pass.

Live agent-device replay --maestro on an Android 37 emulator with a fresh daemon and no other emulators or simulators running:

  • launchApp → pressKey Home → killApp: passes. Logcat shows Killing 5797:com.callstack.agentdevicelab/u0a231 (adj 700): kill background right after am kill. pidof prints nothing, and there are no lowmemorykiller or signal-9 lines.
  • launchApp (stopApp: false): cold start, new pid 6043 (Start proc 6043 … for next-top-activity).
  • launchApp → killApp with the app in the foreground: fails at step 2 with android-kill-requires-background-app. ResumedActivity still names com.callstack.agentdevicelab/.MainActivity, and the process survives.

Unresolved: if am kill runs before the system has finished backgrounding the app (for example a picture-in-picture app), the process may survive and the kill fail. This is an inference; it did not happen in the live runs.

🤖 Generated with Claude Code

@thymikee

Copy link
Copy Markdown
Member

Reviewed 78727e3. I found no code defect, but the kill route has only run against adb fakes, and I have one smaller-design question.

The route (Maestro replay → close with closeAppOnly + killApp → Android closeApplication with mode kill → killAndroidApp: focus check, am kill, pidof check) is exercised only through adb exec fakes, and the PR body names real-emulator am kill behaviour as the open risk (app-lifecycle.ts#L510). Two things are unproven: that am kill reaps an app sent to the background with Home, and that the post-kill pidof check does not misread a cached process that is still alive. Can you run a live Android emulator replay through agent-device replay: launchApp → pressKey Home → killApp → launchApp with stopApp false? It passes when killApp succeeds, adb shell pidof <pkg> prints nothing after it, and the relaunch is a cold start. In the same session, launchApp → killApp with the app in the foreground should fail with android-kill-requires-background-app. Please paste both outputs.

Could this be smaller? Only the Android lifecycle owner reads mode. Could packages/platform-android/src/lifecycle.ts call killAndroidApp directly when mode === 'kill', instead of adding Interactor.kill and a fallback branch to the shared contracts type? That would also make it explicit that owners that ignore mode keep force-stop, which matches what Maestro does on iOS. What would have to change first?

Not blocking: the support matrix and replay-e2e.md do not say that a foreground killApp is refused rather than a silent no-op; a process that survives am kill while in the background (for example a foreground service) gets the same android-kill-requires-background-app reason, which is misleading; and mode?: 'stop' | 'kill' is declared twice although nothing writes 'stop'.

All checks are green and there are no conflicts. I could not check whether upstream Maestro's killApp also refuses a foreground app.

@thymikee

Copy link
Copy Markdown
Member

@Rohit3523 would you like to followup or you're good with me overtaking this?

@Rohit3523

Copy link
Copy Markdown
Contributor Author

Hey! Yeah, I’m going to check the review and push the changes. I’m just waiting for the weekend 😄 It would be great to collaborate and get this thing done together ^_^

@cubic-dev-ai cubic-dev-ai Bot left a comment

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.

7 issues found across 27 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/platform-android/src/__tests__/app-lifecycle-open.test.ts">

<violation number="1" location="packages/platform-android/src/__tests__/app-lifecycle-open.test.ts:239">
P3: These two tests invoke `killAndroidApp` twice because `assertRejectsAppError` (test-utils/app-error.ts) only matches `code`/`message`/`hint`, so a second raw call is needed just to read `details.reason`. In the survivor test the second invocation re-runs the whole failure path, adding another ~2 s of real time (ANDROID_CLOSE_PROCESS_TIMEOUT_MS poll loop) to CI and duplicating identical adb mock traffic. Either extend `ExpectedAppError` with a `details` partial matcher and assert `code`+`hint`+`reason` in one `assertRejectsAppError`, or replace the double call with a single try/catch that asserts code, hint, and `details.reason` together.</violation>
</file>

<file name="src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts">

<violation number="1" location="src/daemon/session-lifecycle/internal/__tests__/session-close-lifecycle-runtime.test.ts:338">
P3: `mockBindDeviceRuntime.mockImplementation(...)` permanently replaces the shared harness mock for the rest of this file: the file's `beforeEach` only calls `mockClear`, which keeps implementations, so the wrapper (and its `seenModes` closure) stays installed for every subsequent test here. Such tests still pass only because the wrapper delegates to `baseBind`, but the shared mock is left hijacked, which silently couples this test to all later tests in the file. Restore the base implementation at the end of the test, or use two `mockImplementationOnce` registrations.</violation>
</file>

<file name="src/daemon/session-lifecycle/internal/session-close.ts">

<violation number="1" location="src/daemon/session-lifecycle/internal/session-close.ts:152">
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.</violation>
</file>

<file name="packages/platform-android/src/app-lifecycle.ts">

<violation number="1" location="packages/platform-android/src/app-lifecycle.ts:512">
P2: `killAndroidPackage` proceeds with `am kill` when the foreground probe cannot produce a state. `getAndroidAppState` converts failed or empty dumps into `{}`, so this guard cannot enforce the documented foreground precondition; fail closed or surface the probe failure before dispatching.</violation>

<violation number="2" location="packages/platform-android/src/app-lifecycle.ts:521">
P1: This final liveness check cannot prove the process is gone because `isAndroidPackageProcessRunning` treats every empty `pidof` result as false, including probe failures. Distinguish the normal no-process exit from command errors and fail when the liveness probe itself is unavailable.</violation>
</file>

<file name="packages/maestro/src/internal/__tests__/runtime-port.test.ts">

<violation number="1" location="packages/maestro/src/internal/__tests__/runtime-port.test.ts:187">
P3: The explicit and config app ids are the same value, so this test cannot distinguish the two paths it claims to cover: the dispatcher computes `{ appId: command.appId ?? request.appId }`, and both commands resolve to `com.example.checkout` regardless of which branch is exercised. A regression that, for example, always used the config app id and dropped the explicit `killApp:` app id would still pass. Use a distinct explicit app id (e.g. `com.example.other`) and assert calls[0] receives it while calls[1] falls back to the config id.</violation>
</file>

<file name="packages/platform-android/src/lifecycle.ts">

<violation number="1" location="packages/platform-android/src/lifecycle.ts:69">
P2: `mode: 'kill'` with an empty `positionals` returns success without killing anything. Fall back to `input.appBundleId` (the session-app identity this binding uses everywhere else) and throw `INVALID_ARGS` when neither exists, so a kill that has no target fails loudly instead of reading as success.</violation>
</file>

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

@@ -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 } : {}),

@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

}
await runAndroidShell(device, ['am', 'kill', packageName]);
await waitForAndroidPackageStopped(device, packageName);
if (await isAndroidPackageProcessRunning(device, packageName)) {

@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 final liveness check cannot prove the process is gone because isAndroidPackageProcessRunning treats every empty pidof result as false, including probe failures. Distinguish the normal no-process exit from command errors and fail when the liveness probe itself is unavailable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/platform-android/src/app-lifecycle.ts, line 521:

<comment>This final liveness check cannot prove the process is gone because `isAndroidPackageProcessRunning` treats every empty `pidof` result as false, including probe failures. Distinguish the normal no-process exit from command errors and fail when the liveness probe itself is unavailable.</comment>

<file context>
@@ -488,6 +488,44 @@ export async function closeAndroidApp(device: DeviceInfo, app: string): Promise<
+  }
+  await runAndroidShell(device, ['am', 'kill', packageName]);
+  await waitForAndroidPackageStopped(device, packageName);
+  if (await isAndroidPackageProcessRunning(device, packageName)) {
+    throw new AppError('COMMAND_FAILED', `am kill did not stop ${packageName}`, {
+      reason: 'android-kill-process-survived',
</file context>
Fix with cubic

}

async function killAndroidPackage(device: DeviceInfo, packageName: string): Promise<void> {
const foreground = await readAndroidForegroundApp(device);

@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.

P2: killAndroidPackage proceeds with am kill when the foreground probe cannot produce a state. getAndroidAppState converts failed or empty dumps into {}, so this guard cannot enforce the documented foreground precondition; fail closed or surface the probe failure before dispatching.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/platform-android/src/app-lifecycle.ts, line 512:

<comment>`killAndroidPackage` proceeds with `am kill` when the foreground probe cannot produce a state. `getAndroidAppState` converts failed or empty dumps into `{}`, so this guard cannot enforce the documented foreground precondition; fail closed or surface the probe failure before dispatching.</comment>

<file context>
@@ -488,6 +488,44 @@ export async function closeAndroidApp(device: DeviceInfo, app: string): Promise<
+}
+
+async function killAndroidPackage(device: DeviceInfo, packageName: string): Promise<void> {
+  const foreground = await readAndroidForegroundApp(device);
+  if (foreground?.package === packageName) {
+    throw new AppError('COMMAND_FAILED', `Cannot kill foreground app ${packageName}`, {
</file context>
Fix with cubic

}
if (input.mode === 'kill') {
const target = input.positionals[0];
if (target) await killAndroidApp(device, target);

@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.

P2: mode: 'kill' with an empty positionals returns success without killing anything. Fall back to input.appBundleId (the session-app identity this binding uses everywhere else) and throw INVALID_ARGS when neither exists, so a kill that has no target fails loudly instead of reading as success.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/platform-android/src/lifecycle.ts, line 69:

<comment>`mode: 'kill'` with an empty `positionals` returns success without killing anything. Fall back to `input.appBundleId` (the session-app identity this binding uses everywhere else) and throw `INVALID_ARGS` when neither exists, so a kill that has no target fails loudly instead of reading as success.</comment>

<file context>
@@ -63,6 +64,11 @@ export function bindAndroidApplicationLifecycle(
       }
+      if (input.mode === 'kill') {
+        const target = input.positionals[0];
+        if (target) await killAndroidApp(device, target);
+        return;
+      }
</file context>
Fix with cubic

},
{ serial: 'emulator-5554' },
async () => {
await assertRejectsAppError(() => killAndroidApp(device, 'com.example.app'), {

@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.

P3: These two tests invoke killAndroidApp twice because assertRejectsAppError (test-utils/app-error.ts) only matches code/message/hint, so a second raw call is needed just to read details.reason. In the survivor test the second invocation re-runs the whole failure path, adding another ~2 s of real time (ANDROID_CLOSE_PROCESS_TIMEOUT_MS poll loop) to CI and duplicating identical adb mock traffic. Either extend ExpectedAppError with a details partial matcher and assert code+hint+reason in one assertRejectsAppError, or replace the double call with a single try/catch that asserts code, hint, and details.reason together.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/platform-android/src/__tests__/app-lifecycle-open.test.ts, line 239:

<comment>These two tests invoke `killAndroidApp` twice because `assertRejectsAppError` (test-utils/app-error.ts) only matches `code`/`message`/`hint`, so a second raw call is needed just to read `details.reason`. In the survivor test the second invocation re-runs the whole failure path, adding another ~2 s of real time (ANDROID_CLOSE_PROCESS_TIMEOUT_MS poll loop) to CI and duplicating identical adb mock traffic. Either extend `ExpectedAppError` with a `details` partial matcher and assert `code`+`hint`+`reason` in one `assertRejectsAppError`, or replace the double call with a single try/catch that asserts code, hint, and `details.reason` together.</comment>

<file context>
@@ -162,6 +162,151 @@ test('closeAndroidApp waits until package process exits after force-stop', async
+    },
+    { serial: 'emulator-5554' },
+    async () => {
+      await assertRejectsAppError(() => killAndroidApp(device, 'com.example.app'), {
+        code: 'COMMAND_FAILED',
+        hint: /Background the app before killApp/,
</file context>
Fix with cubic

sessionStore.set(sessionName, session);
const seenModes: Array<unknown> = [];
const baseBind = mockBindDeviceRuntime.getMockImplementation();
mockBindDeviceRuntime.mockImplementation(async (boundDevice, use) => {

@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.

P3: mockBindDeviceRuntime.mockImplementation(...) permanently replaces the shared harness mock for the rest of this file: the file's beforeEach only calls mockClear, which keeps implementations, so the wrapper (and its seenModes closure) stays installed for every subsequent test here. Such tests still pass only because the wrapper delegates to baseBind, but the shared mock is left hijacked, which silently couples this test to all later tests in the file. Restore the base implementation at the end of the test, or use two mockImplementationOnce registrations.

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/__tests__/session-close-lifecycle-runtime.test.ts, line 338:

<comment>`mockBindDeviceRuntime.mockImplementation(...)` permanently replaces the shared harness mock for the rest of this file: the file's `beforeEach` only calls `mockClear`, which keeps implementations, so the wrapper (and its `seenModes` closure) stays installed for every subsequent test here. Such tests still pass only because the wrapper delegates to `baseBind`, but the shared mock is left hijacked, which silently couples this test to all later tests in the file. Restore the base implementation at the end of the test, or use two `mockImplementationOnce` registrations.</comment>

<file context>
@@ -319,6 +319,56 @@ test('a supported Android close clears admitted runtime hints exactly once', asy
+  sessionStore.set(sessionName, session);
+  const seenModes: Array<unknown> = [];
+  const baseBind = mockBindDeviceRuntime.getMockImplementation();
+  mockBindDeviceRuntime.mockImplementation(async (boundDevice, use) => {
+    const binding = await baseBind!(boundDevice, use);
+    const innerClose = binding.operations.closeApplication;
</file context>
Fix with cubic

killApp: vi.fn(async (input, context) => record(calls, 'killApp', input, context)),
});
const program = parseMaestroProgram(
['appId: com.example.checkout', '---', '- killApp: com.example.checkout', '- killApp'].join(

@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.

P3: The explicit and config app ids are the same value, so this test cannot distinguish the two paths it claims to cover: the dispatcher computes { appId: command.appId ?? request.appId }, and both commands resolve to com.example.checkout regardless of which branch is exercised. A regression that, for example, always used the config app id and dropped the explicit killApp: app id would still pass. Use a distinct explicit app id (e.g. com.example.other) and assert calls[0] receives it while calls[1] falls back to the config id.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/maestro/src/internal/__tests__/runtime-port.test.ts, line 187:

<comment>The explicit and config app ids are the same value, so this test cannot distinguish the two paths it claims to cover: the dispatcher computes `{ appId: command.appId ?? request.appId }`, and both commands resolve to `com.example.checkout` regardless of which branch is exercised. A regression that, for example, always used the config app id and dropped the explicit `killApp:` app id would still pass. Use a distinct explicit app id (e.g. `com.example.other`) and assert calls[0] receives it while calls[1] falls back to the config id.</comment>

<file context>
@@ -178,6 +178,25 @@ describe('MaestroRuntimePort', () => {
+      killApp: vi.fn(async (input, context) => record(calls, 'killApp', input, context)),
+    });
+    const program = parseMaestroProgram(
+      ['appId: com.example.checkout', '---', '- killApp: com.example.checkout', '- killApp'].join(
+        '\n',
+      ),
</file context>
Fix with cubic

@Rohit3523

Copy link
Copy Markdown
Contributor Author

The Compatibility & Provenance → Run Fallow audit failure is pre-existing mainline drift, not introduced by this PR.

I reproduced the audit locally at this PR's head (5058165f1, base a79879) and on a clean worktree of plain main (6428c54, zero PR commits). Both fail with the identical gate-new findings, all in files this PR never touches:

The 4 complexity hits (conformance-normalize.ts, cli/parser/args.ts) are all marked inherited — this PR only adds 2 trivial case returns to already-critical functions. This PR's own scope (pnpm check:affected) is clean.

Suggested path: the owning mainline changes address those three findings (or get path-keyed baseline updates), then a rebase turns this job green. Per repo rules I'm keeping this PR within its command-family scope and not touching those files or regenerating baselines.

@thymikee

Copy link
Copy Markdown
Member

Following up on f894395 after the earlier evidence-pending review at 78727e3.

The kill route (packages/platform-android/src/app-lifecycle.ts#L510) is still only exercised through adb exec fakes: Maestro replay calls close with closeAppOnly+killApp, lifecycle.ts:70 calls killAndroidPackage, which does a foreground probe, am kill, waitForAndroidPackageStopped, then a pidof check. Neither this delta nor any author comment adds the emulator replay the earlier review asked for. Nobody has shown that am kill actually reaps an app that was sent to background with Home on a real emulator, or that the pidof check correctly reads a backgrounded-but-alive process. Either gap could make killApp fail with android-kill-process-survived, or report success without the process being killed. Can you run agent-device replay on an Android emulator with launchApp, pressKey Home, killApp, launchApp (stopApp: false), and paste output showing killApp succeeded, adb shell pidof <pkg> prints nothing afterward, and the relaunch was a cold start? In the same session, please also run launchApp then killApp with the app still in the foreground and paste the failure carrying reason android-kill-requires-background-app.

Not blocking, can be taken or left: the stricter isAndroidPackageProcessRunning (app-lifecycle.ts#L585) now throws android-process-probe-unavailable on any pidof hiccup, which would abort close/stopApp/relaunch paths on a single flaky probe even after force-stop already succeeded, so should a probe failure only fail the operation whose contract needs proof of death, and should closeWithoutSession's mode spread in session-close.ts#L439 get a direct test (closing with {closeAppOnly, killApp} and no stored session, asserting mode 'kill' reaches platform closeApplication) since nothing exercises it today; also the killAndroidApp re-export in mechanics.ts#L86 looks like dead leftover code with no consumer at head, and the PR body still references the old Interactor.kill design and a different tested commit, so worth cleaning up before merge.

Compatibility & Provenance ran fallow against the pre-rebase base and its findings sit in files this PR does not touch (session-test-harness.ts, runner-contract.ts, runner/index.ts, the apple/harmonyos recovery.ts pair, run-script-http.ts, install-source-network-transport.ts, args.ts); the one file this PR does touch, conformance-normalize.ts, only gets new cases in the lifecycle sub-helpers, not in the flagged canonicalizeUpstreamCommand or canonicalizeAgentCommand, so this failure looks unrelated to the change. I did not re-run the author's reproduction on main, so I can't confirm main actually reproduces the failure being fixed here. I also did not run the unit suites; the regression read above comes from reading the diff and the pre-change code, not from a run. I can't say which Android images in the fleet lack pidof or emit stderr noise from it, so the probe-hardening impact is plausible, not confirmed. And I don't know whether upstream Maestro's killApp also refuses a foreground app.

The remaining requirement before this can merge is the live Android emulator replay described above: killApp after Home must cold-kill the app (empty pidof, cold relaunch), and killApp on a foreground app must fail with android-kill-requires-background-app.

@Rohit3523

Copy link
Copy Markdown
Contributor Author

Ran the requested live Android emulator replay (agent-device replay --maestro, real emulator, real installed app, emulator-5554). It found and fixed a real gap — thank you for pushing on this instead of accepting the unit-only evidence.

What the live run found

Flow: launchApp → killApp (app never backgrounded), expecting android-kill-requires-background-app.

The refusal didn't fire. With a temporary debug probe in killAndroidPackage, the daemon log showed exactly why:

DEBUG-KILLPROBE packageName="chat.rocket.android" foreground={"package":"com.google.android.apps.nexuslauncher","activity":"...NexusLauncherActivity"}

adb shell dumpsys activity activities run independently at the same instant showed chat.rocket.android as ResumedActivity — the app was genuinely still foreground. getAndroidAppState tries dumpsys window windows first, falls back to dumpsys window (this Android 16 image has no mCurrentFocus in the windows variant, per the existing code comment), and returns whichever dump answers first. Right after a heavy RN app's cold launchApp, WindowManager's focus dump can still name the previous foreground app for one read while ActivityManager (dumpsys activity activities) already shows the new activity resumed — input-focus transfer lags activity resume. killAndroidPackage trusted that single stale read and skipped the refusal.

Fix (e676aded6, pushed): killAndroidPackage now corroborates a "not the target" foreground read once after a 150ms settle before trusting it enough to proceed — same-app reads still refuse immediately (refusing early is always safe). Added killAndroidApp refuses when a corroborating read shows the target back in the foreground to app-lifecycle-kill.test.ts, which fails without the fix and passes with it. Full suite (573 tests) + typecheck + lint still green.

Caveat: couldn't get a clean live confirmation of the background-kill + cold-relaunch case

This dev machine had several concurrent iOS simulators + an xcodebuild UI-test run + this Android emulator all running at once. Logcat shows the emulator's low-memory killer SIGKILLing freshly-launched processes (ours and several unrelated system packages) ~4 seconds after launch, independent of anything agent-device does — e.g. Process chat.rocket.android (pid 10844) has died: fg TOP followed immediately by Zygote: Process 10844 exited due to signal 9 (Killed). That makes the background scenario's replay output (which reported success) not trustworthy evidence on its own: I can't tell whether am kill reaped it or the OOM killer already had. I don't have a quieter device to rerun this on right now — happy to rerun on request, or if you can point me at a less-loaded emulator/CI lane.

The foreground-refusal fix itself doesn't depend on that confound — it's proven by the debug-instrumented live read above (a value, not a pass/fail) plus the new deterministic unit test.

The three non-blocking cleanups

  • PR body: updated — removed the stale Interactor.kill design description (that field doesn't exist; only CloseApplicationInput.mode?: 'kill' does) and the stale tested-commit reference.
  • killAndroidApp re-export in mechanics.ts: confirmed dead (the real caller, platform-android/src/lifecycle.ts, imports directly from app-lifecycle.ts; nothing imports it via mechanics.ts) and removed.
  • closeWithoutSession mode test: added session-close-without-session-mode.test.ts — closes with {closeAppOnly, killApp} and no stored session, asserts mode: 'kill' reaches the platform closeApplication.

isAndroidPackageProcessRunning probe-hardening

Agree there's a real issue, but it's narrower than "any probe failure aborts close/stopApp/relaunch": waitForAndroidPackageProcessGone's own retry loop (2s budget, 50ms poll) is what should absorb a single transient pidof hiccup — right now the probe's throw escapes that loop instead of being retried within its existing budget, which affects stopApp/closeAndroidApp too, not just killApp. The final "did it survive" checks (this PR's android-kill-process-survived, and implicitly stopApp's completion) should stay fail-closed — treating an unreadable probe as proof of death would be the wrong direction. Fixing the loop's own tolerance touches pre-existing stopApp/closeAndroidApp behavior shared with this PR, so I've left it out of this change rather than expand scope; can follow up in a focused PR if you'd like it done now instead.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

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.

All reported issues were addressed across 4 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread packages/platform-android/src/app-lifecycle.ts Outdated
@thymikee

Copy link
Copy Markdown
Member

This is a follow-up on f894395 (#2749 (comment)) at e676ade, and the fixes there don't close the gap.

readAndroidForegroundAppSettled (packages/platform-android/src/app-lifecycle.ts#L579) still re-reads the same source that went stale in the live run, just 150 ms later. getAndroidAppState ranks WMS mCurrentFocus ahead of the AMS resumed activity (window-state.ts ANDROID_WINDOW_QUESTIONS.foreground), and three inputs still take the wrong branch on that ranking. If the focus lag after launchApp outlasts 150 ms, which a heavy RN cold start can do, the refusal is skipped, am kill does nothing to the resumed process, and the call fails about 2 s later with android-kill-process-survived and a hint that wrongly claims the app "was backgrounded." If the target is resumed while a system window owns focus, the #592 case the window-state comment already names, the same wrong outcome happens. And the mirror case: the same-app fast path trusts one stale read right after pressKey Home, so the documented legal flow (Home, then killApp) gets refused with android-kill-requires-background-app. So killApp can give the wrong typed reason and hint in either direction. The precondition needs to come from whatever source actually decides if am kill can reap the process, the AMS resumed/top activity or process state, not WMS focus, in both directions: add a resumed-activity question to ANDROID_WINDOW_QUESTIONS in window-state.ts over the existing ANDROID_RESUMED_ACTIVITY_DUMPS tier, have killAndroidPackage read it, then drop the sleep-and-reread helper and its comment. Add a fixture with stale launcher focus plus an mResumedActivity naming the target, which must refuse, and one with target focus plus a resumed launcher, which must proceed.

Not blocking: the new JSDoc and the test headers at app-lifecycle.ts#L565, app-lifecycle-kill.test.ts:53, and session-close-without-session-mode.test.ts:44 carry dated live-run narrative and a "#2749 review:" history that AGENTS.md asks to keep out of implementation comments, worth removing once the fix above lands but not a reason to hold this up on its own.

The iOS runner XCTest testSynthesizedReplacementPacesAnAppOwnedFieldAtItsAcknowledgeWindow (RunnerTests+SynthesizedTextEntryTests.swift:120) is failing in CI, but this PR touches no Swift or runner file, its route is Maestro → daemon close → Android lifecycle, and main recently changed that exact fixture in 94433db, so that failure looks unrelated to this change. The Fallow findings are against a stale base (a798792, 918 changed files); every flagged symbol, clone, and complexity hit sits in a file this PR doesn't touch, or is a pre-existing export (android test-utils/app-error.ts assertThrowsAppError) whose line only moved, identical on main, so a rebase should clear those. There are no known conflicts.

I didn't run the unit suites; the test-validity read above comes from reading the pre-delta and head code, not from executing it. I also don't know how long the WMS focus lag actually runs after launchApp or after Home on a real device, so the launch-lag and Home-lag cases in the finding above are marked likely rather than confirmed, and I haven't checked whether upstream Maestro's own killApp refuses or auto-backgrounds a foreground app. The author's background-kill replay was confounded by the OOM killer by their own account, so am kill's actual reap-and-cold-relaunch behavior is still unproven.

Before this can merge, the kill precondition needs to switch to the AMS resumed activity as above, and then needs a live emulator replay, on an emulator with no concurrent simulators or xcodebuild and no lowmemorykiller/signal 9 for the package in logcat: agent-device replay --maestro for launchApp → pressKey Home → killApp → launchApp (stopApp: false) should show killApp succeeding, adb shell pidof <pkg> printing nothing right after killApp, a logcat line showing am kill did it ("Killing : ... kill background", not the OOM killer), and a cold relaunch (new pid, or am start -W LaunchState COLD); in the same session, launchApp then killApp with no Home should replay-fail with android-kill-requires-background-app, not android-kill-process-survived.

@thymikee

Copy link
Copy Markdown
Member

Reviewed at e3387ef. The precondition still comes from the wrong source, and there's no live proof the shipped behavior actually kills a backgrounded app.

readAndroidForegroundAppSettled (packages/platform-android/src/app-lifecycle.ts#L573) still calls getAndroidAppState for both reads, and both resolve to WMS mCurrentFocus (window-state.ts ranks ANDROID_WINDOW_FOCUS_DUMPS first, lines 48-50). Sleeping 150ms between two WMS reads doesn't change the source, so the cases from the last round stay open: focus lag past 150ms after launchApp still lets a kill proceed against the launcher, a system window holding focus per #592 still lets a kill proceed against the wrong package, and focus lag after Home can still refuse a legal kill. The delta adds a new one: launchApp then killApp with no Home, where the first read correctly names the target but the second read catches a transient focus owner (IME, dialog, permission controller) and the code at line 581 returns that package instead, so a kill that used to be refused immediately now proceeds and does nothing. Is the fix here to stop asking WMS focus at all, in either direction, and instead add a resumedActivity question to ANDROID_WINDOW_QUESTIONS backed only by ANDROID_RESUMED_ACTIVITY_DUMPS, since that's the source that actually decides whether am kill can reap the process? If so, killAndroidPackage should read that instead, and readAndroidForegroundAppSettled, ANDROID_KILL_FOREGROUND_STABLE_MS, and its JSDoc can go, with the WMS-sequence tests replaced by two AMS fixtures: stale launcher focus plus mResumedActivity naming the target must refuse, target focus plus a resumed launcher must proceed.

The kill precondition that actually touches the device changed twice, at e676ade and again here. The only live run so far was on older code with a debug probe, and its background-kill result was confounded by lowmemorykiller SIGKILLs, so there's still no live evidence that the shipped precondition refuses a foreground target, allows a backgrounded one, or that am kill (not the OOM killer) is what reaps the process and that the next launch is cold.

Once the resumed-activity fix above lands, this needs a live replay on the shipped commit, on an emulator with no concurrent simulators or xcodebuild running and no lowmemorykiller or signal-9 lines for the package in logcat. Run agent-device replay --maestro for launchApp → pressKey Home → killApp → launchApp (stopApp: false), and in the same session launchApp → killApp with no Home. The first flow needs to show killApp succeeding, adb shell pidof <pkg> printing nothing right after, a logcat kill background line from am kill, and a cold relaunch (new pid, or am start -W reporting LaunchState: COLD). The second flow needs to fail with android-kill-requires-background-app, not android-kill-process-survived.

I didn't run the unit suites; the read above is from tracing the code before and after the delta, not from execution. I also don't know how long WMS focus lag runs on real devices after launchApp or Home, or how often a transient focus owner shows up 150ms after launch, so I'm calling the first finding likely rather than confirmed. Compatibility & Provenance (ci.yml#L196) runs the Fallow audit, the unused-export check, corpus blob verification, and wire-compat rules; I don't have a failed-step log for it. This PR only touches app-lifecycle.ts (a private helper) and two test files, adds no export, and changes no wire contract, so a delta-caused failure looks unlikely, but I can't rule it out without the log. Smoke Tests, Repo Guards, and Coverage were still running and I didn't wait on them.

The precondition needs to move off WMS focus onto the AMS resumed activity through a window-state question, the sleep-and-reread helper needs to go, and then the live Android replay described above needs to run clean before this is ready to merge.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

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.

All reported issues were addressed across 4 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread packages/platform-android/src/window-state.ts Outdated
Comment thread packages/platform-android/src/app-parsers.ts Outdated
Comment thread packages/platform-android/src/__tests__/app-lifecycle-kill.test.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment •

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.

All reported issues were addressed across 3 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Fix all with cubic | Re-trigger cubic

Comment thread packages/platform-android/src/__tests__/app-parsers.test.ts
@thymikee

Copy link
Copy Markdown
Member

This is a follow-up on the earlier review at e3387ef. The precondition changed again, from WMS focus to AMS resumed activity: https://github.com/callstack/agent-device/blob/23ea708/packages/platform-android/src/app-lifecycle.ts#L511. No live replay has run on 23ea708; the only prior run predates e676ade and was confounded by lowmemorykiller SIGKILLs. So there is still no proof that the shipped precondition allows a backgrounded kill, that am kill (not the OOM killer) reaps the process, that the next launch is cold, or that a foreground target is refused with the typed reason. Please run the replay from the earlier review on 23ea708 or later, with no concurrent simulators or xcodebuild running: launchApp, pressKey Home, killApp, launchApp (stopApp:false) must show killApp succeeding, an empty adb shell pidof <pkg> right after, a logcat kill background line from am kill, no lowmemorykiller or signal-9 lines for the package, and a cold relaunch (new pid or am start -W LaunchState: COLD); then in the same session, launchApp followed by killApp must fail with android-kill-requires-background-app, not android-kill-process-survived. Paste the dumpsys activity activities resumed line from both states.

Not blocking: getAndroidResumedActivity returns {} both when adb fails and when AMS answered with nothing resumed (for example during a transition or with the keyguard up, https://github.com/callstack/agent-device/blob/23ea708/packages/platform-android/src/app-lifecycle.ts#L519), and killAndroidPackage maps both to android-process-probe-unavailable even though only the dump-unreadable case should refuse this way; this can wait, but consider giving "nothing resumed" its own reason or letting it proceed.

I did not run the unit suites; the claim that tests fail on the old code comes from tracing the pre-delta code, not from a run. I also don't know whether real devices can show no resumed record between Home and killApp, so the note above stays a question rather than a confirmed finding. The delta adds topResumedActivity= to the focus markers as well, so it now also feeds the foreground check in app-state.ts as a low-priority fallback; it looks harmless but I haven't checked it against real dumps, and neither have I checked multi-display or split-screen dumps where more than one resumed record can appear (the parser takes the first match). The fallow audit ran against a base 117 commits behind the merge base, covering many more files than this PR touches; of the two flagged files this PR does touch, both findings are pre-existing and only in scope because the files were edited, and the delta itself adds no unused export, so this looks unrelated to the change. The remaining step before merge is the live Android replay described above, showing the backgrounded kill and cold relaunch, and the foreground kill correctly refused with android-kill-requires-background-app.

Maestro killApp is system-initiated process death. On Android it dispatches
am kill after reading the AMS resumed activity: a resumed target is refused
with android-kill-requires-background-app, and a process that survives fails
with android-kill-process-survived. Other platforms ignore the mode and keep
an ordinary close.

Co-authored-by: Rohit <40559587+Rohit3523@users.noreply.github.com>
@thymikee
thymikee force-pushed the feat/maestro-killapp-impl branch from 23ea708 to 4661ed9 Compare October 14, 2026 17:02
@thymikee

Copy link
Copy Markdown
Member

@Rohit3523 thanks for driving this. I rebased the branch onto current main as a single commit (you're the co-author) and closed out the remaining review items: the kill reads the AMS resumed activity, a dump that answers with nothing resumed now proceeds, the strict pidof check is limited to the kill, the kill code loads lazily, the docs name the backends that run it as stopApp, and the preconditions are listed as a documented deviation. The live emulator evidence is in the PR body. Your earlier history is still at 23ea708 if you want to compare.

🤖 Generated with Claude Code

@thymikee

Copy link
Copy Markdown
Member

The code at 4661ed9 looks good, and the earlier evidence request from the review of 23ea708 is now met. Not blocking: parseAndroidResumedActivity (app-parsers.ts:105) repeats the line and marker loop from parseAndroidFocusSegment (app-parsers.ts:85) and adds a fourth inline copy of the package/activity regex, so could parseAndroidFocusSegment take a markers parameter and could one shared component parser serve all four sites? You can take or leave this.

The live Android 37 replay (logcat "kill background", cold relaunch pid 6043, foreground refusal) is from your report in the PR body. I could not observe it and did not rerun it. I also did not check whether an Appium-backed Android route uses bindAndroidApplicationLifecycle. The docs name ADB-backed Android and say WebDriver-provider backends run killApp as stopApp, which matches the single mode read site.

CI on 4661ed9 is still pending, with 9 jobs queued or in progress and no failures so far. These jobs cover the changed Android lifecycle, Maestro runtime port and daemon session-close routes. CI must go green before merge. There are no conflicts.

The open bot threads no longer apply and can be resolved. All are fixed at head:

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 14, 2026
@thymikee
thymikee merged commit b16d46d into callstack:main Oct 14, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants