Skip to content

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

Open
Rohit3523 wants to merge 129 commits into
callstack:mainfrom
Rohit3523:feat/maestro-killapp-impl
Open

Rohit3523 wants to merge 129 commits 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 (system-initiated process death) end to end, mirroring stopApp plumbing. On Android it dispatches adb shell am kill with its own foreground/liveness preconditions; every other platform ignores the kill mode and falls through to an ordinary close (stopApp-equivalent). No new public CLI surface — killApp stays inside the Maestro compatibility layer.

  • contracts: CloseApplicationInput.mode?: 'kill' threaded through closeApplication
  • android: killAndroidApp (am kill), with a foreground refusal (android-kill-requires-background-app), a probe-unavailable refusal (android-process-probe-unavailable), and a survived-kill refusal (android-kill-process-survived)
  • maestro: killApp IR kind, parser, runtime port, daemon projection to an app-only close carrying killApp dispatch
  • daemon: replay dispatch folds killApp; session close (both the stored-session and no-stored-session closeWithoutSession paths) passes mode: kill into closeApplication
  • upstream/116_kill_app now classifies identical; declared divergence removed. Touched 31 files.

Validation

Tested commit e676aded6. Focused suites pass: contracts interaction, maestro parser/runtime-port/daemon-port/conformance-verify (14 pass), android app-lifecycle, session-close, replay-maestro-request, fuzz arbitraries, maestro help/docs sync. pnpm check:quick passes. pnpm check:affected --run: fallow audit clean on changed files. Unresolved: check:production-exports reports 68 unused exports, reproduced identical on origin/main, pre-existing.

Live Android emulator replay (agent-device replay --maestro) found and fixed a real foreground-detection race in killAndroidPackage; see the PR review thread for the evidence and remaining caveats.

Maestro killApp triggers system-initiated process death (adb shell am
kill on Android) instead of stopApp's force-stop. On other platforms it
aliases stopApp through the shared close dispatcher.

- contracts: optional Interactor.kill, CloseApplicationInput.mode, with
  fallback to close in invokeApplicationClose
- android: killAndroidApp (am kill) wired onto the interactor
- maestro: killApp IR kind, parser, runtime port, daemon projection to
  an app-only close carrying killApp dispatch, conformance canonical
- daemon: replay dispatch folds killApp; session close passes
  mode kill into closeApplication
- upstream/116_kill_app now classifies identical; divergence removed
executeLifecycleCommand grew a sixth case for killApp and tripped the
complexity gate. The stopApp/killApp/clearState legs share one shape,
so dispatch them through a lookup instead of three switch cases.
am kill only reaps background processes, so a foreground killApp used
to succeed without killing anything. Refuse a foreground target naming
the background-first precondition, and verify the process is gone after
the kill instead of reporting an unproven success.
@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 and others added 26 commits September 22, 2026 07:45
…emon (callstack#2736)

* fix(ios): serve a simulator recording whose recorder died with its daemon

A simctl reattach answered `missing` as soon as the recorder process was gone,
which is the ordinary state after daemon loss, so a retried `record stop` threw
`resource-missing` forever even with the recorder's video on disk. The simctl
descriptor now carries the caller-facing export coordinates, so a proven-gone
recorder whose file still passes the container sniff reattaches as a handle that
runs the same stop-and-export sequence the live recording would have run, and
discloses the touch overlay whose events did not survive the daemon.

A manifest written without those coordinates is answered exactly as it was
before, because it cannot name an export it never recorded.

* fix(ios): resume a recovered simulator export from what the first stop journaled

A recorder that died with its daemon proved nothing about the export its file can
still become, and neither did a manifest whose stop had already collected a copy or
journaled a finalization. Reattach decided from the recorder's own path alone, so a
retry after those steps found no file there and reported a loss the manifest
contradicted. It now asks what a resumed stop would still have to read, which the
shared stop sequence answers from the checkpoints the first attempt wrote.

The coordinates a recovered export needs are the caller-facing keys of the live
snapshot, so the facet is a Pick of it, encoded and restored by spread, and validated
by the recording-facts validator Android's descriptor already needed — moved to
capture-kit so both backends answer with the same strictness. A recovered handle
refuses an overlay whose gesture events died with the daemon instead of running the
overlay pass with nothing to burn in and then reporting it unavailable.

* chore(gates): declare the capture-kit recording-facts subpath in the boundary enumeration
…ead (callstack#2188) (callstack#2750)

* fix(ios-benchmark): wait out unmounted fixture screens during setup admission

The proxy leg stopped on `fixture-anchor` for every deep-linked screen it
reached: the persistent client reads the anchor right after `apps.open`, and
that read lands before the app mounts its first tree. Measured on the fixture
app, the snapshot taken immediately after an `open --relaunch --launch-url`
returns the single Application node, while a read 1.5s later returns 31 nodes
with the anchor. The fresh-process CLI leg hides the same gap behind its
per-read process spawn.

Setup reads are untimed, so the shared admission now re-observes until the
expected anchor is exposed, bounded by FIXTURE_ANCHOR_ADMISSION_BUDGET_MS, and
still stops the run on the same typed reason when a fixture never exposes it.
An unsuccessful observation keeps failing immediately rather than polling.

Also repairs the test module's stale `FixturePreparationResult` import: the
scripts directory is erased rather than typechecked, so the rename to
`FixtureOperationResult` went unnoticed.

* docs(evidence): record the iOS snapshot convergence final head

The sweep that gated callstack#2188 on callstack#2199 completed at `7c434b5758`: cold/cold-cold,
first-interaction, warm/relaunch with the package-size leg, and the 0/20/80 ms
proxy matrix, every leg clean and free of failed samples. The four Markdown
summaries ride along; the record states what the numbers prove and the
deviations that bound them — target and runtime, host load, the fixture app's
changed trees, and the 196-commit span behind the package-size delta.

Two findings that should not travel quietly: the warm snapshot daemon cost is
about double the baseline under a controlled side-by-side diagnostic, and the
callstack#2198 slice corpora come from unmerged branch heads, so they are not parity
baselines for anything.

* chore(gates): pin the iOS snapshot convergence final corpus

`PUBLISHED_EVIDENCE` becomes `PUBLISHED_CORPORA`: the pinned evidence is now two
corpora measured at two revisions on two runtimes, each carrying its own tag and
evidence commit, and `PUBLISHED_EVIDENCE` derives from them. A missing file is
now fetched with a hint naming the corpus that actually holds it, instead of a
single tag that is wrong for one of them. Disjointness, naming, and the tag and
commit each corpus is cited by are tests rather than comments.

Also stops exporting the setup admission's internals, which the dead-code audit
rightly flags.
…space (callstack#2737)

The fold response's `screen.widthPt`/`heightPt` are the lit panel's native
points (pixels divided by its point scale, never rotated), not the next
snapshot's viewport. Add the required `screen.coordinateSpace: "native-panel"`
discriminator at the construction site, export it from the owning contract so
the Apple owner and the MCP schema share one value, and correct every doc and
message that implied these numbers could place a tap.

A fresh snapshot stays the only source of app-viewport coordinates: on an open
Duo the 669x951 inner panel hosts a 951x669 app window.

Closes callstack#2729
* feat(cli): read a recording with record contact-sheet

* chore(gates): publish the contact-sheet capture-kit facade
…ck#2724)

* fix(ios): rotate synthesized gestures in the capture viewport

The synthesized lane derived its rotation frame from
XCUIScreen.main.screenshot() image size while capture normalizes node
rects into the acquisition viewport (app.frame). On an iPhone Duo in the
open pose the runner's main screen is the dark outer panel, so dispatch
was not the inverse of capture and taps drifted by a constant offset
(hit the Home tab by luck, missed Settings). Use app.frame as the
reference frame for every synthesized lane so dispatch is capture's
inverse on any panel and orientation, and assert that inverse as a
round-trip unit test including the Duo inner panel (669x951, rot90).
Drops the per-gesture main-screen screenshot.

* fix(ios): route foldable interactions through the resolved window

* test(apple): read snapshot capture guard from the acquisition module

captureSnapshotRootBounded moved to RunnerTests+SnapshotAcquisition.swift with the
capture split; the source-shape guard still read RunnerTests+Snapshot.swift, failing
the Coverage job.

* feat(ios-runner): log synthesized dispatch geometry and record routing

The dispatch point after rotation, the reference window it was computed against, and
the display a record is routed to are the three facts that decide whether a
synthesized event lands. runner.log now carries them (SYNTHESIZED_DISPATCH,
SYNTHESIZED_RECORD) so delivery regressions are readable without instrumenting a
build. Documents the origin invariant CoordinateSpaceRotation relies on.

* fix(ios-runner): keep the os(iOS) directive on its own line in synthesizedCoordinateContext

The formatter had joined it to the opening brace, which the selection scanner's
line-start directive match misses, so the else branch parsed as unmatched.

* refactor(ios-runner): resolve the interaction window through one helper

The viewport, the interaction root, and the synthesized reference frame each
walked app.windows with their own exists/frame rules, so a sheet or a fold
could move one consumer without the others. resolveRunnerWindow is now the
single walk: the first window with a non-empty frame, the application
otherwise. Alert activation logs the chosen button's label and frame center
so a tap that lands beside its button is visible in runner.log.

* fix(ios-runner): route synthesized display IDs through the resolved window

The gesture reference frame is booked against the window resolveRunnerWindow
picks, but record creation re-walked windows.firstMatch for the display ID.
With an empty window 0 beside a lit window N the reference lands on N while
the gesture routes to window 0's display, or fails outright. The bridge now
takes the resolved window: the gesture entry points accept it, record
creation reads the display from it, and every remaining firstMatch geometry
read (keyboard avoidance, touch reference frame, system edge gestures) goes
through the shared resolver. The interaction anchor and its frame come from
one resolution pass, keeping the coordinate offset on the window the tap was
measured against. Unit tests cover empty-window-0, absent windows, and the
pure window-index rule.

* fix(ios-runner): anchor iOS coordinate interactions at the app origin

`interactionCoordinate` resolved the interaction root through
`resolveRunnerWindow`, so an iOS coordinate tap/double-tap/long-press/drag
anchored on the first qualifying window and subtracted its frame origin. On
SpringBoard that first window is not necessarily the alert's — the wallpaper or
status-bar window can qualify first — so `alert accept` (reached via activateElement,
tapAt, performCoordinateTap) missed the Open button, which is why iOS Smoke failed
at "wait for Automation lab" after the deep-link confirmation.

Restore the main-branch iOS app-origin anchor (app.coordinate(0,0) + the
snapshot-space point). Reference frames and the synthesized display ID still
resolve through `resolveRunnerWindow`, so a foldable tap keeps its panel and
routes on the resolved window's display. macOS keeps the window-relative anchor.

Refs callstack#2724

* refactor(ios-runner): fail a gesture with no resolved window directly

The gesture callers resolve the window in Swift before the call, so a nil
resolved window already means no window qualified. Walking
windows.firstMatch a second time in record creation could only book a display
that every Swift caller had refused to book geometry against; the missing
window is now the error.

* fix(ios-runner): refuse a synthesized context with no resolved window

resolveRunnerWindow reports "no window" honestly: it returns nil alongside
app.frame when no window has a non-empty frame. synthesizedCoordinateContext
still built a context from that frame, so a synthesized drag or pinch carried a
nil window into record creation and failed with "no resolved application
window" on a case main dispatched. The context now refuses to exist without its
window, which sends the gesture down the XCUITest fallback that already covers
it and lets SynthesizedCoordinateContext.resolvedWindow be non-optional.

Refs callstack#2724

* refactor(ios-runner): drop the unused application display ID resolver

Every synthesized gesture routes its display through the window it already
resolved for geometry, so the wrapper that re-walked windows.firstMatch had no
production caller left. Its assertions move to RunnerResolveWindowDisplayID, so
the resolve-before-read ordering, the primary-window identity, and both refusal
branches stay under test.

Refs callstack#2724
…allstack#2738)

Drives one booted iPhone Duo through closed -> half-open -> open -> closed
and asserts the app reacts after every pose change: a tab press routes and
selects, Add to cart moves the cart counter, a scroll reveals a canary, a
long press moves its dedicated count, and one multipointer pinch changes a
recognized scale. Coordinates are derived from the current target's bounds
and refs are re-resolved after each fold, never reused across a pose change.

Local/manual coverage only; GitHub Actions cannot schedule it yet (no hosted
Duo runtime or registered Duo runner). --demo-geometry-mutation derives the
pinch origin from a different control's bounds to prove the scale assertion
is sensitive to targeting geometry.

Refs callstack#2731
…stack#2755)

* docs(adr): record scroll clip authority for iOS snapshot presentation

An interactive snapshot of a list dropped every row after a row holding
selectable text: a UITextView is a UIScrollView, XCTest publishes its
indicator inside the text, and the presentation attributed that indicator
to the surrounding list, so the list's visible band became one line of
text and everything below it was clipped (callstack#2214, fixed by callstack#2740).

The type-specific fix closed one instance. ADR 0026 records the rule that
keeps the class out: indicator ownership is a fact the producer reports,
the visible band stays host interpretation, and an inferred attribution
gets hint authority only — it cannot eject a node. Reshape and eject
become separate rule APIs, scroll capability becomes a matrix instead of
one boolean answered five different ways, and every ejection carries a
typed reason the differential can check.

Design contract: callstack#2754.

* docs(adr): read scroll indicator ownership from the parent edge

Review found the first draft built eight contracts on top of a bug that a
parent-edge lookup removes. UIKit publishes an indicator inside its own
scroll view and projection preserves that edge, because every scroll host
is regular-eligible in both languages; the ancestor walk past the parent is
what misattributed the text view's indicator in the first place. Reading the
parent instead retires the proposed IosRunnerPresentation field, its
capture-local indexing and remapping, the ownership capability matrix, and
the two-tree split.

Also corrects the band's premise: the pinned Settings tree has the
CollectionView frame at 0-874 with an indicator track at 116-812, so the
frame spans the bars and safe area rather than reporting a content extent.
Drops the two-API reshape/eject split, which does not hold against ~23
suppression sites across ten rules, and against a reshape surface that still
accepts rect. Keeps the decision: ownership is read, ejection needs
evidence, every removal carries a disposition.

Measured while revising: the full unit suite passes with parent-edge
ownership, and a synthetic WebView row keeps rows that main drops.

* docs(adr): name scrollarea as the iOS-scoped exception

Review asked the eligibility claim be scoped, since isScrollableSnapshotType
also accepts scrollarea and neither eligible set contains it. Verified the
scope is real rather than hypothetical: macOS desktop capture reaches these
rules through snapshot-desktop-surface -> ios-snapshot-runtime ->
publishIosSnapshot, and its helper emits ScrollArea from AXScrollArea while
the iOS runner never does. So "every scroll host is regular-eligible" is
written as an iOS claim, with a ScrollArea decision owed on that surface
before the parent-edge rule is reused there.

Also names the divergence left vague before: only tree.ts accepts scrollarea.
Counts made exact while there: 23 suppression sites across ten rules, and
scroll and noise-overlay already patch rects while ejecting.

* docs(adr): scope visibility ejection and name the real rect-rewriting pair

The removal row read as if every ejection needed a clip-fold result or a
parent-edge owner. Nineteen of the 23 sites delegate semantics or drop
decoration and keep their own authority, so the row and the paragraph now
name the four visibility-driven sites the ADR actually governs, plus the
clip fold's inclusion decision, and a second row states that compaction is
out of scope.

Corrects the rect-rewriting pair: transitions rewrites a field's rect and
ejects the title and image in one pass, while noise-overlay patches rects
in a presentation function that receives replacements only and ejects from
a separate suppression function. That module already has the split the
rejected API proposed, so it is cited as the precedent instead.

Names why the runtime guard was rejected: a threshold either fires on
ordinary compaction or is loose enough to pass a 74 to 20 collapse. Cites
callstack#1797 for the fold's leak class with callstack#1784 adjacent instead of claiming one
class, drops the drafting narrative from the refuted field, records the
unpinned self branch, and notes the table's own self case.
…callstack#2741)

* fix(ios): capture the display that owns a window, not the main screen

A foldable lights one panel at a time while XCUIScreen.main names one fixed
panel, so an app on the other panel was captured as a valid PNG of black. The
runner now asks the target app's window first and the system surface's window
second, reads the display off that same window instance, and encodes the
capture upright, reporting the display ID, the pixel size it encoded, and the
pixels-per-point of that image so the host normalizes density from the capture
instead of a panel it guessed.

The home screen is SpringBoard's window, so a capture with no session app still
has a display that owns a window; a capture fails closed with a typed reason
only when neither resolves one. An app with no window is refused on the query's
own answer rather than on geometry alone. The screenshot command skips the
app-activation preflight, so it resolves the requested bundle id for observation
without ever foregrounding an app to see which display it is on.

Co-authored-by: Apex <noreply@callstack.com>

* test(ios-runner): restore the window-path order proof and refuse typed screens

The fixture resolved on the exists read, so the window-path tests resolved
the window before the call and could no longer see a screen-first change
route a foldable gesture to display 1. The frame read resolves again, the
exists pre-reads are gone, and two new refusals pin the window path to the
typed screen reason: a window answering display ID 0 and a window whose
screen read raises both fail with no resolved window display ID. The window
resolver's frame read now sits inside the exception catcher, so the raise
answer carries the same typed window refusal and log line the application
walk gives.

* test(ios-runner): pin the caught frame read on the window display path

The window resolver moved its frame read inside the exception catcher, but
no test drove a frame lookup that raises, so the catcher was carried on the
comment alone. KVC raises on a key the runtime stops answering, which is how
a private XCTest read fails when it renames, and the gesture path hands this
window across a command boundary. The case now expects the typed window
refusal and leaves the caller's display ID untouched.

---------

Co-authored-by: Apex <noreply@callstack.com>
…ain carries it (callstack#2757)

callstack#2747 approved packages/capture-kit/src/recording/contact-sheet.ts over its
domain-facade ceiling. Main now carries the entry, so the no-growth rule
governs it and the row can no longer change a verdict. The stale-row check
defers on main itself, so every branch based on main after callstack#2747 failed
Coverage with "no APPROVED_OVER_CEILING row is stale".

Co-authored-by: Claude <noreply@anthropic.com>
…tack#2754 step 1) (callstack#2758)

Ownership of a scroll indicator is now read from the producer's parent
edge, passing up only through ancestors that share the scroller's exact
frame, instead of walking for the nearest scrollable or text node. A
scroll-typed node labelled as an indicator describes itself and owns
nothing. This stops an indicator from a scroll-shaped host that publishes
as a non-scroll type (a WebView row, a map, a paged cell) from clipping
the enclosing list to the host's band (callstack#2214), while a WKWebView page
whose wrappers fill the ScrollView keeps its visible band (callstack#1784/callstack#1797).
The walk stops at the first frame change, so a host smaller than its
list still owns nothing. ADR 0026.
…r macOS (callstack#2754 step 4) (callstack#2759)

`REGULAR_ELIGIBLE_TYPES` (projection.ts) and `eligibleInteractiveTypes`
(SnapshotPresentationProjection.swift) are one fact in two languages: they keep
a scroll host alive through regular projection so the parent-edge ownership rule
can find it. No test referenced either list, so they could silently drift.

`eligibility-parity.test.ts` reads both literals from source, normalizes them
with the runtime's `normalizeType`, and fails on drift (verified by planting an
extra member). A production export was rejected in favor of source parsing so
the sets stay module-private.

The `scrollarea` asymmetry is settled for the macOS surface with a case rather
than by editing a set for iOS's sake: the desktop path
(snapshot-desktop-surface -> ios-snapshot-runtime -> publishIosSnapshot) reaches
these rules and its helper emits `ScrollArea`. A content-bearing `ScrollArea`
survives eligibility and owns its band under parent-edge ownership; a label-less
one is dropped and its indicator resolves no owner, which under-clips (safe)
instead of mis-clipping the enclosing list — strictly safer than the retired
ancestor walk. So the type stays out of both eligible sets, and the guard pins
that exclusion.

Co-authored-by: opencode <noreply@opencode.ai>
* feat(ios): support timed hinge keyframes

* fix(ios): preserve fold timelines in replay and verify boundary angles

* fix(ios): terminate guest fold animation on cancellation

* chore(gates): classify timed fold keyframes as device-observable

* chore(ci): retrigger checks for fold trajectories
)

When 0.21.0 made the host AX bridge the iOS Simulator snapshot source, the bridge node reader mapped the traits word into enabled but never into selected, so a selected: selector or Maestro selected: qualifier stopped matching the active tab, segment, or checked row. Restore the fact by reading the selected-trait bit of the same word, publishing selected: true only when set and omitting it otherwise to match the XCTest tree. Pin the bit against the guest's real captured words and fold the traits parse into one shared source so a producer encoding change cannot drop it silently.
…allstack#2712) (callstack#2765)

* fix(ios): pay for one toolchain in the snapshot bridge identity read

The identity read probed `xcodebuild -version` and then `xcrun --sdk iphonesimulator
--show-sdk-version` — two Xcode-owned binaries, each a tool the host can stall on before
it answers. On CI the `xcrun` probe lost twice at the shared 30 s ceiling and took the
whole iOS smoke job down over a cache-key field (60.9 s of a 120 s budget, job
106068926059).

The Simulator SDK ships inside the selected `Xcode.app`, so the version and build
`xcodebuild -version` already reports pin it: the field left the cache key and the exec
left the identity read. The `xcrun clang` the build runs pays that wait once, under the
build's own ceiling, where a stall of that length fits instead of exhausting the budget.

A probe or compile that cannot answer now says so at the seam that waited: `timeout` with
`toolchain-probe-stalled` or `native-build-stalled`, the command, the budget each attempt
was armed with, and the exec layer's kill kept as the cause. Before, a job got a bare
`xcrun timed out after 30000ms` stack that reads exactly like a device failure.

Cache entries written before this hash to a different key, so they rebuild once instead of
being misread; no schema bump is needed to prove that.

Closes callstack#2712

* test(ios): take the snapshot bridge stall fixtures from the exec layer

Both stall fixtures built the timeout error by hand, so a probe could keep classifying a
kill `exec.ts` no longer reports as one. They now run a command the exec layer really
kills and carry whatever error it raises; the probe assertions still read the shape
through `isCommandTimeoutError` rather than a message.

Review note on callstack#2765.
thymikee and others added 14 commits September 24, 2026 18:47
…callstack#2896)

* perf(ios): reduce smoke critical path with affected XCTest selection

* chore(gates): enforce iOS XCTest impact selection coverage

* fix(ci): run bridge proof after macOS live replay

* chore(gates): guard macOS replay before bridge proof

* chore(gates): derive iOS XCTest selection from Swift guards and ownership

* chore(gates): resolve shallow PR impact and iOS screenshot coverage
…ceholder (callstack#2927)

The helper wrote hint-showing but never the hint itself, and text holds the
hint only while the field is empty, so a filled field's placeholder was
readable nowhere. The helper now writes hint (getHintText, API 26+), the
node carries it as placeholder whether or not it is showing, and the fact
joins the unchanged map and the find/get digest. Value and label are
unchanged.
…allstack#2916)

* fix(ios): derive the fold request envelope from a worst-case ledger test

FOLD_REQUEST_TIMEOUT_MS was a literal whose basis lived only in a
comment, and that comment kept drifting from the route: it first cited
one 30s helper build and four 20s hinge reads, missing two 5s
display-inventory reads and the HID-dispatch guard/grace, then callstack#2858
hand-summed the route again for its own preparation-phase change and
landed a fresh hand-summed figure with the same failure mode.

Add a ledger test that drives fold through the public client and
daemon against a fake Apple tool provider recording each call's
timeoutMs, including the fold-helper cache's toolchain probes and
build. A calibration run learns the route's hinge-settle-attempt count
from an oscillating angle that never settles; a measured run uses the
same oscillation but lands the last allowed read on target, so it
succeeds after making exactly as many reads. Summing every call's
timeout (minus 1ms, plus any kill grace) gives the route's worst case
that still succeeds, asserted against the resolved command envelope
with the usual 30s daemon-result margin. The test imports no
platform-apple step figure other than MAX_FOLD_DURATION_MS, so it
keeps proving the bound however the route's steps change next.

Widen FOLD_REQUEST_TIMEOUT_MS to 255_000 (the smallest 5s multiple at
least ledger + margin) and point both the constant's comment and the
descriptor-timeout-policy pinning test at the ledger test instead of a
hand-summed figure.

* fix(ios): keep a daemon-result margin over prepare's default runner budget

readPrepareIosRunnerTimeoutMs's fallback and the client envelope both
resolved to PREPARE_REQUEST_TIMEOUT_MS (240_000), so with no
--timeout the daemon-side runner budget and the client envelope were
identical: a slow cold runner build ended in a client-side timeout and
daemon reset instead of the daemon's own typed runner_phase_budget
result. Every other bounded command keeps a 30s margin between its
daemon-side budget and its envelope; prepare's explicit-timeout case
already widened past its own base by that same margin
(widenToUserBudget), only the default case had none.

Split the daemon-side runner budget out as PREPARE_STARTUP_BUDGET_MS
(240_000, unchanged) and derive PREPARE_REQUEST_TIMEOUT_MS from it
plus the margin (270_000). The handler falls back to the startup
budget, not the now-wider request timeout.

Add a handler-level test that calls handlePrepareCommand directly with
a fake runner binding recording the timeoutMs it receives, then checks
that value plus the margin against the same request's resolved client
envelope for the no-flag, --timeout above default, and --timeout below
default cases - the rule the fix proves, not just the constants it
compiles to.

* fix(ios): fail the fold ledger test on an unbounded provider call

The fold worst-case ledger fake defaulted an unset timeoutMs to 0, so a
provider call reaching it with no bound cost 0 virtual ms instead of
exposing the true unbounded worst case. Its runCommand handler also
skipped `open -a Simulator` before recording, dropping any such call
from the ledger with no error.

Build the ledger on the shared recording provider and assert every
call carries a finite, positive timeoutMs, so an unbounded call fails
the test instead of passing silently.

* test(ios): give the fold ledger test a cold-import budget

The ledger test runs two cold folds through a fresh daemon each. After a
platform-apple source change the first import is untransformed, and the
test ran past vitest's 5 s default, which hid the ledger printout behind a
timeout. Match the provider-scenario precedent of an explicit budget.
…readable (callstack#2895)

* fix(android): return from an app open only after the launched app is readable

am start -W returns when the activity draws its first frame, which can be a
splash or an empty root while a React Native app still mounts. The first
capture after a cold open --relaunch then saw a content-poor tree and spent
its own budget on re-captures. The open now captures the launched app through
the interactor snapshot path, whose content verdict and bounded re-capture
decide readiness, and reports postOpenObservation. An app that stays
unreadable, or a failed capture, still opens as unobservable.

Refs callstack#1571 (iOS half: callstack#2838).

* fix(android): bound and type the launch observation of an app open

The open's launch capture now runs behind an injected launch observation
port with a fixed 6 s window of its own, separate from the caller's
cancellation. Only a content verdict, a system surface over the app, or
the window running out reads as unobservable. Any other capture failure
is a typed probe-failed result that the open reports and survives.

The capture is transient: it borrows a running helper session and stops
only a session it started, installs no helper, and does not retire the
helper after a content verdict. A URL open reports no observation, and
an app open whose package cannot be read reports app-unidentified.
PostOpenObservation is one documented union in the lifecycle contract,
shared with the Apple owner.

Refs callstack#1571

* fix(android): let the launch settle window end re-captures, never helper work

The open's 6 s window reached the snapshot helper as an abort, so a
window that closed during a cold helper start or a borrowed capture tore
the helper down and left the next read to recover it. The window is now a
settle deadline on the transient capture: the content re-capture loop
starts no attempt after it, while helper start, capture and teardown keep
their own budgets and only the caller's signal cancels them.

The transient read replaces the injected port, the borrow session scope
and the interactor-side install mapping: the capture itself keeps a session
it found, releases one it started, installs no helper and does not retire
it after a content verdict.

Refs callstack#1571

* fix(android): keep a missing-helper refusal out of capture failure recovery

A transient capture on a device without the current helper refused at the
install check, and that refusal went through the capture failure handler,
which logged an error and force-stopped the helper runtime on every new
device's first open. The refusal now reaches the caller directly.

The transient-capture tests move to their own file, so snapshot.test.ts
stays under the test-file size ratchet.

Refs callstack#1571
…tack#2893)

* fix(wait): report readiness work that consumed the wait budget

A wait whose deadline cancels a poll while the iOS XCTest runner is still
starting, or while the Simulator app is still being discovered, now fails
with wait_readiness_exhausted and details.readinessPhase instead of
wait_capture_stalled. The platform names the phase on the cancellation it
throws; the wait classifies the cancelled poll as readiness.

Refs callstack#2343

* fix(wait): tag only running discovery as readiness and keep platform imports type-only

* fix(wait): keep an earlier retriable refusal ahead of the readiness verdict

* test(ios): type the route test tool mocks against readonly simctl args

* refactor(ios): name the route's cancelled target-resolution rethrow

* test(ios): pin the resolver's discovery and re-check cancellations
…d enum (callstack#2888)

* refactor(ios-runner): close the snapshot quality verdict state vocabulary

The runner wrote the verdict `state` as a free String while the host accepts only
healthy | recovered | sparse, and the contracts annotation reader cast any string state into
the verdict type. A typo or a one-sided rename therefore dropped the verdict and its
disclosure.

`SnapshotQualityState` now owns the runner side, raw-value Codable keeps the wire JSON
unchanged, and `reasonCode` stays open. The kernel states the vocabulary once as
`SNAPSHOT_QUALITY_STATES` with `isSnapshotQualityState`; capture-kit and contracts both read
through it, so neither keeps a second accepted-state set and the annotation reader rejects an
unknown state instead of casting it. `contracts/fixtures/ios-snapshot-quality-states.json` is
the table the Swift `allCases` order and the kernel tuple are each pinned to.

* refactor(substrate): hold the verdict state map in each eager-frozen reader

The readers reached the vocabulary through `isSnapshotQualityState` in `kernel/snapshot.ts`, which
made that module eager in six entry closures the eager-closure gate holds at its merge-base size —
the contracts capture façade at 9 modules, capture-kit's verdict reader at 2. The only module those
two closures already evaluate is the one whose edge the gate rejects, so no single runtime home for
the set exists.

Each reader now keys a `Record<SnapshotQualityState, true>` over the kernel union, the home the
gate prescribes for code that has to live where it is already evaluated. Membership goes through
`Object.hasOwn`, so an inherited key is never a state, and a state added to the tuple without a key
in a reader is a compile error there: the guarantee the shared import was bought for, without the
eager edge. Both reader tests walk the tuple and the kernel test still pins it to the fixture.

* refactor(snapshot): gate the backend name, and stop pinning vocabulary order

The annotation reader was one predicate plus a blanket cast. `backend` names the recovery strategy
in the warning line, so it goes through a declared map keyed against the kernel union now, and
capture-kit lost both of its `as` casts on the way: the strategy is gated on the capability table it
already imports, the reason code on an exhaustive map over its union.

A full projection in contracts was tried and the repo's own gates refuse it — fallow reports a
4-group, 104-line clone family against capture-kit's normalizer, on top of the eager-closure gate
that already forbids a shared reader. The re-read stays in the shape `readTargetActivation` in the
same file uses: check the two names that decide presentation, forward what this module published, and
pin the pair payload-by-payload from capture-kit's test.

Order pinning drops out. The fixture is compared as a set on both sides, the stamping test asserts
each case's own raw value, and the unread 28-line capture builder and its import are gone.

* refactor(contracts): name the verdict re-read apart from the strict reader

Two same-named readers with different trust levels is how the half-migration read as validating more
than it does. This module's version checks the two load-bearing names and forwards what this repo
published, in the shape `readTargetActivation` uses, so it is now `readPublishedSnapshotQualityVerdict`
and the strict per-field reading keeps the plain name for capture-kit's untrusted-payload reader.

* refactor(snapshot): answer the review round on sharing, parity, and evidence

The eager-closure refusal the review asked to be pointed at or filed is not a table of budgets: the
gate ratchets every entry's closure against the committed merge-base tree, so the numbers to read are
9 for `facades/capture.ts` and 2 for `snapshot-quality-verdict.ts`. Re-installing one shared kernel
predicate for both readers on this head fails six entries and names both of those, which is why the
vocabulary stays a map per reader; recorded on callstack#2872 as the accepted deviation.

The `android-helper` asymmetry is not real: `SNAPSHOT_QUALITY_BACKEND_CAPABILITIES` merges the
Android declaration, so both readers accept that strategy and the parity table now carries the row
instead of omitting it. The client-side drop of an unknown state or backend gets its CHANGELOG entry,
the justification comments shrink to one constraint sentence each, the kernel test keeps only its
`@ts-expect-error`, and the word left dangling by the last rename goes.
…allstack#2886)

* test(platform-apple): rename the macOS surface interactor test

* fix(macos): derive surface routing and crop classification from one owner

Add a SessionSurface-keyed MACOS_SURFACE_BACKENDS table in contracts with
macOsSurfaceBackend and a table-derived MacOsHelperSurface brand. The
platform-apple snapshot, screenshot, readText, press, runtime capture and
native find sites, and the daemon crop classifier, now read it.

The crop classifier now reports frontmost-app as macos-helper (the helper
captures it) and a surface-less macOS session as macos-app-window (every
route serves it through the runner). Helper entry points require an
owner-routed surface.

Fixes callstack#2880

* test(macos): assert the surface loader's refusal message, not only its class
…hree fixed points (callstack#2926)

* fix(android): fill verification samples until a deadline instead of three fixed points

The typed text reaches the accessibility tree when the app renders it,
which a React Native controlled input on a loaded emulator did later than
the three fixed samples allowed (at 0, 150 and 500 ms elapsed: sleeps of
0, 150 and 350 ms before each): an otherwise passing fill failed twice on
a 4-vCPU CI emulator while the field still showed its hint. Sampling now
continues every 150 ms until the text has held for two consecutive samples
or 1.5 s have passed, and the sample taken at the deadline is the answer.
A fast device confirms after two samples, one fewer than before.

* test(android): the fill sampler paces itself by an injectable clock, so its tests advance time instead of waiting it out

* docs(android): the fill sampler's comment states the sampling rule, not the history behind it
…napshot node (callstack#2913)

* fix(android): carry the checked state of a checkable control on the snapshot node

The snapshot helper never serialized `checked` or `checkable`, and the host reads only the helper's XML, so no later layer could recover it: a switch, checkbox, or radio button looked the same on or off, `get attrs` had no `checked` field, and snapshot text showed the control as plain. The helper now writes `checked` on every node Android reports as checkable, with both answers, so an unchecked switch reads `false`, a node that cannot be checked reads nothing, and so does a helper older than the attribute. The parser, the Android hierarchy node, and the published snapshot node carry it to `get attrs`, the unchanged-snapshot comparison, the selector digest, and the `[checked]` and `[unchecked]` markers in snapshot text. Both answers render because the diff compares the state and a checkable control rendered as plain would hide that it toggles.

* fix(daemon): the interaction outcome weighs the states stateMarkers prints, and a scroll's container check matches content by identity

Review follow-up. The interaction surface key folded enabled and selected but not checked, so a tap whose only effect was a toggle read as a no-op on the outcome lane while the diff called it a change, and a no-change retry would tap the switch straight back. The key now spreads stateMarkers, so the outcome lane, the unchanged-snapshot comparison, and the diff weigh one list.

With checked in the key, a flip inside a scroll container beside an unrelated change elsewhere (the status clock) read as content moving within the container. discriminatingSurfaceChangedWithinRect now matches entries on the flip-tolerant identity where one exists, told apart by document order when repeated, so a state flip at the same rect is not movement.

Tests: a checked-only flip is a change on the outcome lane; a flip at the same rect is not movement and a moved row is; the scroll claim is withheld for a flipped toggle inside the container; checked in both answers invalidates snapshot reuse and survives the selector digest.

* test(daemon): the capture-retry fixtures spell the interaction key with state markers

The two hand-written pre-signatures carried the literal enabled and unselected segments the key no longer writes for a plain node, so the post-tap capture read as changed and the retry never fired.

* fix(daemon): a surface entry carries a state-free content key, so an anonymous toggle is not movement

Review follow-up. The within-container movement check fell back to `key` for an entry without an identity, and `key` carries the checked state, so an unlabelled switch a swipe brushed still read as content moving. Every entry now carries `content`: the identity where the node has one, else its type and role; the check compares on that alone.

* fix(maestro): the snapshot signature weighs checked, so a toggle tap is not a no-op to retap

maestroSnapshotSignature hashed label, value, enabled, selected, focused
and bounds; a switch, checkbox or radio whose only observable effect is
its checked state produced the same signature before and after the tap,
so the settle and the no-change retry read the tap as a no-op and could
tap it back. checked joins the hashed object the way the interaction
outcome key already weighs it.
…ack#2931)

Six of the sixteen `test()` registrations in this file were byte-identical
copies of three others: "keeps delayed typing in typed-input mode" appeared
twice, and "tolerates delayed React Native text verification" and "reports
clear error when unicode input is unsupported" appeared three and four times.
Block-level diffing confirms one distinct body per name, so the extra copies
registered extra passes that no production change could distinguish.

They arrived already duplicated in 6dd7d40 and survived two file moves.
Deleting them removes ~3s of repeated fake-adb wall clock from the file.

Verified the surviving copy still owns the delayed-typing contract: dropping
`chunkSize: delayMs > 0 ? 1 : ANDROID_INPUT_TEXT_CHUNK_SIZE` in text-input.ts
turns "fillAndroid keeps delayed typing in typed-input mode" red, and no other
test in the family catches that decision.
…n its snapshot metadata (callstack#2928)

Android bounds are physical pixels and so are the points press and the
gestures take; nothing said what a pixel was worth, so a consumer that
lays out in dp had no way to place a tap or read a size. The helper now
reports DisplayMetrics.density beside its capture metadata (the same
configuration the framework lays out with, a wm density override
included), and the host publishes it as androidSnapshot.pixelDensity,
the display's physical pixels per dp (2.625 on a 420 dpi phone). Rects
stay in physical pixels; an older helper omits the field.
…es (callstack#2930)

The `@internal` AWS Device Farm and BrowserStack factory pairs claimed to be
"used by integration tests" while no test or production module referenced them.
`createCloudWebDriverProviderDefinitions` composes each provider's runtime
directly and is the only construction path, so both factories were the
superseded pre-definitions shape.

Removing them also removes `resolveConfiguredBrowserStackCapabilities`, whose
webdriverCapabilities-shadowing precedence the live path never applied: it
passes `buildCloudWebDriverBaseCapabilities(platform, deviceName)` as
`configured` instead.
…ble TS and Swift both verify (callstack#2900)

* test(apple-runner): pin production-built runner requests in a golden table

Add contracts/fixtures/runner-requests.json: one entry per production
request site, captured at its real entry point. Three producer tests own
the entries: the platform-apple request sites, the runner-internal sites
(real HTTP bodies from the fake runner), and the two recording modules.
runner-contract.test.ts asserts every RunnerCommand has an entry, names
are sorted and unique, and the drive code builds no request itself.

Refs callstack#2881

* test(apple-runner): verify the runner request golden table in Swift

Every fixture request decodes as Command and re-encodes with the same key
set, every CommandType case has a request, and every stored field of
Command, SequenceStep and RunnerGesturePlan appears in some request.
CommandType becomes CaseIterable for the case check.

Refs callstack#2881

* chore(gates): keep runner-requests.json entries on one line each

Refs callstack#2881

* test(apple-runner): pin the macOS desktopScroll pixels request

* test(apple-runner): share the runner-requests fixture check with root tests

Export the fixture reader as @agent-device/platform-apple/runner/requests-fixtures
so root producers pin requests through the same commandId normalization, and derive
the request-literal guard's sources from the producers the fixture declares.

Refs callstack#2881

* test(apple-runner): drive root recording requests from a dedicated golden file

The request-literal guard scans every fixture producer, so root behavior
tests that doubled as producers had to weaken their exact request
assertions. Move the root recording drives into
src/__tests__/screen-recording-runner-requests.test.ts, restore the
original literals, and pin the recordStop sent without an app bundle.

Producers must now be *runner-requests.test.ts files that call
assertProducedRunnerRequests(import.meta.filename, ...), so a comment no
longer satisfies the check. The runner-internal drive moves to
runner/__tests__/runner-requests.test.ts to follow that rule.

Refs callstack#2881
@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 ^_^

Distinct survived-kill reason, direct Android kill without shared Interactor change, foreground-refusal docs, exhaustive lifecycle dispatch.

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

Copy link
Copy Markdown

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

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

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

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

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

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

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

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

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

Maestro killApp triggers system-initiated process death (adb shell am
kill on Android) instead of stopApp's force-stop. On other platforms it
aliases stopApp through the shared close dispatcher.

- contracts: optional Interactor.kill, CloseApplicationInput.mode, with
  fallback to close in invokeApplicationClose
- android: killAndroidApp (am kill) wired onto the interactor
- maestro: killApp IR kind, parser, runtime port, daemon projection to
  an app-only close carrying killApp dispatch, conformance canonical
- daemon: replay dispatch folds killApp; session close passes
  mode kill into closeApplication
- upstream/116_kill_app now classifies identical; divergence removed
executeLifecycleCommand grew a sixth case for killApp and tripped the
complexity gate. The stopApp/killApp/clearState legs share one shape,
so dispatch them through a lookup instead of three switch cases.
am kill only reaps background processes, so a foreground killApp used
to succeed without killing anything. Refuse a foreground target naming
the background-first precondition, and verify the process is gone after
the kill instead of reporting an unproven success.
Distinct survived-kill reason, direct Android kill without shared Interactor change, foreground-refusal docs, exhaustive lifecycle dispatch.
…523/agent-device into feat/maestro-killapp-impl

# Conflicts:
#	packages/platform-android/src/lifecycle.test.ts
- session-close: propagate kill mode through closeWithoutSession
- android: fail kill closed when foreground probe cannot answer;
  distinguish pidof probe failure from reaped process
- android lifecycle: fall back to session app identity for kill target,
  INVALID_ARGS when no target exists
- tests: single-call reason assertions via details matcher,
  mockImplementationOnce for mode capture, distinct killApp app ids
@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.

- Move killAndroidApp tests to app-lifecycle-kill.test.ts so
  app-lifecycle-open.test.ts stays under the 1,000-line tripwire
- Stub the platform close in the session-close mode-plumbing test
  instead of executing the real Android kill against live adb
@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.

…eeds

Live emulator replay (agent-device replay --maestro) reproduced a real gap
from the PR review: immediately after launchApp, dumpsys window's
mCurrentFocus can still name the previous foreground app for one read while
dumpsys activity activities already shows the launched app resumed.
getAndroidAppState takes whichever dump answers first, so killAndroidPackage's
single-sample foreground check could read "backgrounded" for an app that was
still genuinely in the foreground, skipping the android-kill-requires-
background-app refusal. killAndroidPackage now corroborates a "not the
target" read once after a short settle before trusting it, mirroring the
existing gone-twice-in-a-row pattern used elsewhere in this file.

Also: drop the killAndroidApp re-export from mechanics.ts (no consumer
imports it there; the real caller imports directly from app-lifecycle.ts),
and add a direct test proving mode: 'kill' reaches the platform close when
closeWithoutSession handles close with no stored session.

Claude-Session: https://claude.ai/code/session_01NVfRR5Pmmkg8ZstHQUZ2qR
@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

Choose a reason for hiding this comment

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

2 issues found across 4 files (changes from recent commits).

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/app-lifecycle.ts">

<violation number="1" location="packages/platform-android/src/app-lifecycle.ts:582">
P3: The same-app fast path skips the settle in the mirror direction. The stale WMS `mCurrentFocus` lag this fix documents also applies to a read taken right after the user backgrounds the app, so `killApp` issued immediately after `pressKey: Home` can still see the target named and spuriously refuse a legal kill — the hint then tells the user to background an app they already backgrounded. Corroborate this direction too: settle and re-read, refusing only when the confirmatory read still names the target (fail closed on an unreadable confirm).</violation>
</file>

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

<violation number="1" location="src/daemon/session-lifecycle/internal/__tests__/session-close-without-session-mode.test.ts:62">
P3: The wrapper discards everything except `input.mode`, so the test stays green if the no-session close later drops or corrupts `positionals`, `surface`, `outPath`, or `ensureReady: true`. Record the full input and assert the key fields alongside `mode` so this path's dispatch contract is pinned, not just the kill bit.</violation>
</file>

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

Fix all with cubic | Re-trigger cubic

Comment on lines +582 to +584
if (first?.package === packageName) return first;
await sleep(ANDROID_KILL_FOREGROUND_STABLE_MS);
return await readAndroidForegroundApp(device);

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The same-app fast path skips the settle in the mirror direction. The stale WMS mCurrentFocus lag this fix documents also applies to a read taken right after the user backgrounds the app, so killApp issued immediately after pressKey: Home can still see the target named and spuriously refuse a legal kill — the hint then tells the user to background an app they already backgrounded. Corroborate this direction too: settle and re-read, refusing only when the confirmatory read still names the target (fail closed on an unreadable confirm).

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 582:

<comment>The same-app fast path skips the settle in the mirror direction. The stale WMS `mCurrentFocus` lag this fix documents also applies to a read taken right after the user backgrounds the app, so `killApp` issued immediately after `pressKey: Home` can still see the target named and spuriously refuse a legal kill — the hint then tells the user to background an app they already backgrounded. Corroborate this direction too: settle and re-read, refusing only when the confirmatory read still names the target (fail closed on an unreadable confirm).</comment>

<file context>
@@ -561,6 +562,28 @@ async function readAndroidForegroundApp(device: DeviceInfo): Promise<AppStateRun
+  packageName: string,
+): Promise<AppStateRuntimeResult | null> {
+  const first = await readAndroidForegroundApp(device);
+  if (first?.package === packageName) return first;
+  await sleep(ANDROID_KILL_FOREGROUND_STABLE_MS);
+  return await readAndroidForegroundApp(device);
</file context>
Suggested change
if (first?.package === packageName) return first;
await sleep(ANDROID_KILL_FOREGROUND_STABLE_MS);
return await readAndroidForegroundApp(device);
if (first?.package === packageName) {
await sleep(ANDROID_KILL_FOREGROUND_STABLE_MS);
const confirmed = await readAndroidForegroundApp(device);
if (confirmed && confirmed.package !== packageName) return confirmed;
return first;
}
await sleep(ANDROID_KILL_FOREGROUND_STABLE_MS);
return await readAndroidForegroundApp(device);
Fix with cubic

operations: {
...binding.operations,
closeApplication: async (input: Parameters<typeof innerClose>[0]) => {
seenModes.push(input.mode);

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The wrapper discards everything except input.mode, so the test stays green if the no-session close later drops or corrupts positionals, surface, outPath, or ensureReady: true. Record the full input and assert the key fields alongside mode so this path's dispatch contract is pinned, not just the kill bit.

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-without-session-mode.test.ts, line 62:

<comment>The wrapper discards everything except `input.mode`, so the test stays green if the no-session close later drops or corrupts `positionals`, `surface`, `outPath`, or `ensureReady: true`. Record the full input and assert the key fields alongside `mode` so this path's dispatch contract is pinned, not just the kill bit.</comment>

<file context>
@@ -0,0 +1,85 @@
+      operations: {
+        ...binding.operations,
+        closeApplication: async (input: Parameters<typeof innerClose>[0]) => {
+          seenModes.push(input.mode);
+        },
+      },
</file context>
Fix with cubic

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

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants