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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# concurrent Maestro sessions against the same device break flows

Symptom: a Maestro flow fails mid-run with element-not-found or a hierarchy that belongs to the
other flow's screen, while a second `maestro test` targets the same device UDID (observed
2026-07-29 on login-ui-d051: a `logout.sh` run failed while another Maestro session against the
same simulator was active; the failure was self-inflicted, not a product defect).

Cause: Maestro's per-device driver (XCUITest on iOS, uiautomator on Android) is single-tenant.
Two concurrent sessions fight over the same accessibility connection; taps and captures
interleave.

Fix: never overlap two `maestro` processes against one device. Before starting a run, check
`ps aux | grep "maestro.*--device"` for the same UDID. If a flow fails inexplicably, first rule
out your own concurrent session before classifying anything as a product defect.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Android emulator claim race on shared adb

Symptom: `pnpm dev:mobile:android claim emulator-5554` refused: "claimed by
/Users/igor/Projects/.worktrees/pr-review-d957" — for an emulator I had just launched myself.

Cause: adb serials are host-global. The runbook order (launch → bounded boot wait → claim →
build) leaves a window between adb visibility and claim; a concurrent worktree's polling loop
claimed my fresh emulator at first visibility (claim record bootId matched my instance's
/proc/sys/kernel/random/boot_id exactly, claimedAt within seconds of first visibility).

Fix: claim AT adb visibility (before waiting for sys.boot_completed). If refused because the
other worktree won the race, do NOT drive the device (never use a device claimed by another
worktree) and do NOT kill it either if your qemu owns it — boot a different AVD/serial instead.
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Android dev-client cold start after force-stop: deep link required, rebundle is slow

Symptom: after `adb shell am force-stop com.kilocode.kiloapp`, relaunching with
`am start -n com.kilocode.kiloapp/.MainActivity` lands on the Expo dev-client launcher
("Development Build", server URL list) instead of the app; `monkey -p ... LAUNCHER 1` fails
outright. The app then shows a blank white screen with a single-node, zero-text uiautomator dump
for tens of seconds.

Cause: the dev client needs the metro URL passed via the preflight deep link
(`exp+kilo-app://expo-development-client/?url=<url-encoded http://127.0.0.1:$METRO_PORT>`, see
`apps/mobile/e2e/preflight.sh`). After force-stop the JS bundle is re-fetched and re-built by
metro: 25–65s on emulator on this machine (slower under load with two emulators).

Fix: always relaunch via the deep link (and ensure `adb reverse tcp:$API_PORT` / `tcp:$METRO_PORT`
are set first). A blank white screen + zero-text dump within ~60s of a cold start means LOADING,
not a transparency defect — wait and re-dump before classifying. Contrast with the real iOS
transparency defect: static texts VISIBLE on screen while the interactive subtree is absent from
the a11y tree for 60s+ and only an app relaunch recovers it. Also note Maestro's `settle-app`
assertions in helper flows can time out inside this rebundle window — re-run the flow rather than
classifying the timeout.
33 changes: 33 additions & 0 deletions .kilo_workflow/learnings/mobile-android-edge-to-edge-ime-insets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# mobile/android: on API 35 the app window NEVER resizes for the IME — verify resize claims via dumpsys frames, not layout shifts

Symptom (login-ui-d051 r0/r0b): with `android:windowSoftInputMode="adjustResize"` in the
generated manifest, the Android login screens showed zero layout shift for the email IME
but a uniform 373px shift for the OTP number pad — looking like "the OS resizes for one
IME but not the other".

Cause: on API 35 the window carries `pfl=EDGE_TO_EDGE_ENFORCED` + `fl=LAYOUT_IN_SCREEN
LAYOUT_INSET_DECOR`, so `sim={adjust=resize}` is nominal only — the OS never shrinks the
window for any IME. The IME arrives purely as a `WindowInsets` source. All layout movement
is app-side: RN's `KeyboardAvoidingView behavior="height"` is inert (the ScrollView stays
full-height because the window frame never changes), and the only thing that moves content
is JS consuming `Keyboard` events — on the login screens that is the OTP form's
`bottomSpacer = keyboardHeight + 16` padding, which grew the form view by exactly 746px
(268dp + 16dp) under the number pad and re-centered the `justify-center` container by
spacer/2 = 373px. `keyboardDidShow` DOES fire with a real height on Android for the number
pad (268dp = IME height above the nav bar); a listener that attaches while the keyboard is
already up observes 0 and wrongly concludes the spacer is inert.

How to verify (no code changes):

- `adb shell dumpsys window windows` (the single-subcommand form — full `dumpsys window`
abbreviates per-window entries and omits `Frames:`/`mFullConfiguration`). Compare the
app's `winConfig={ mBounds / mAppBounds }` before/after IME: identical rects = no OS resize.
- `dumpsys window | grep "type=ime frame"` gives the authoritative keyboard top/height
(`InsetsSource id=3 type=ime frame=[0,top][w,h] visible=...`) — better than pixel scans.
- uiautomator container bounds arbitrate the app-side mechanism: full-height ScrollView +
grown form-view height = JS spacer padding; shrunk ScrollView = KAV height.

Also: on the RN login ScrollView, any `input swipe`/drag blurs the focused field and hides
the IME (`keyboardShouldPersistTaps="handled"` governs taps only) — there is no
swipe-scroll-while-IME-up fallback on Android; and a tap on a keyboard-covered control
lands on the IME window and types into the focused field (app-side no-op, no navigation).
18 changes: 18 additions & 0 deletions .kilo_workflow/learnings/mobile-android-global-render-wedge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Android app renders nothing (white) mid-session despite healthy Metro/API

Symptom: dev client loads the bundle ("Running 'main'" in ReactNativeJS) but mounts zero RN
views — screen stays uniform white (native splash never hidden), uiautomator shows only
FrameLayout/ComposeView containers, no errors anywhere. Onset mid-session: Android login UI
rendered fine at 08:56 and 09:18, then every app start (any emulator, fresh VM, -wipe-data,
pm clear, Metro restart, dev-menu Reload, force-stop + deep link) rendered white from ~09:19 on,
while the iOS app kept rendering the same signed-out login branch through the same Metro/API.

Not the cause (all eliminated): AVD disk state (-wipe-data did not help), app data (pm clear),
emulator instance (two AVDs), network (toybox nc to reversed 5300/10381 OK), API health (200s),
Metro process (restarted; serves android bundle 200 10.3MB to host curl).

Suspected: serving-side dev-handshake/bundle state regression on a shared machine with a
concurrent verifier active ("Cannot connect to Expo CLI" seen in ReactNativeJS near a Metro
restart). If it recurs: capture `adb logcat -s ReactNativeJS` for the Expo CLI warning, curl
localhost:<metro>/status, and compare against an iOS control app before burning hours on
emulator-level recoveries — none of them work.
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# mobile: device-auth per-IP pending limit is machine-wide — and the holder may be STUCK rows, not live traffic

Symptom: `POST /api/device-auth/codes?app=1 500` with
`Error: Too many pending authorization requests from this IP` (`src/lib/device-auth/device-auth.ts`),
app shows the error branch "Failed to start sign in. Please try again." — while the app, backend,
and network are all healthy.

Cause: the limiter counts PENDING device-auth codes per IP, machine-wide (MAX 5), regardless of
`expires_at`. Two distinct holders can saturate it: live traffic from a concurrent section, or
STUCK rows abandoned by client-side-only `cancel()` (see
`mobile-device-auth-stuck-pending-rows.md`). Observed 2026-07-29 on login-ui-d051: 500s
08:37–10:44+ across two verifier rounds were first attributed to the live pr-review-d957
section — WRONG; a read-only DB probe showed 5 rows stuck since 04:39–04:46, no live traffic
involved. Waiting NEVER clears stuck rows (no dev cleanup cron).

Fix: probe the DB first (recipe in `mobile-device-auth-stuck-pending-rows.md`) — 5 pending rows
long past `expires_at` = stuck, reconcile via the real poll endpoint; recent rows = a live
holder, then treat `start()` calls as a scarce budget (<=3 per round) and classify the pending
branch as environment-blocked if 500s persist past one spaced recovery attempt. The error
branch rendering is itself a free branch-swap evidence point (probe it for full alpha).
26 changes: 26 additions & 0 deletions .kilo_workflow/learnings/mobile-device-auth-stuck-pending-rows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# mobile: device-auth pending rows get STUCK after client cancel — probe the DB, reconcile via the real poll endpoint

Symptom: every `POST /api/device-auth/codes?app=1` 500s with `Too many pending authorization
requests from this IP` and the app shows "Failed to start sign in" — and waiting any amount of
time (hours) never clears it, even after all concurrent sections end.

Cause: the limiter counts `status='pending'` rows per IP regardless of `expires_at`
(`apps/web/src/lib/device-auth/device-auth.ts`, MAX 5). The app's `cancel()` is CLIENT-SIDE ONLY
(`use-device-auth.ts`: setState idle, no DELETE) and a killed/closed app never polls again, so
every abandoned code sits `pending` forever. Local dev runs no `cleanupExpiredDeviceAuthRequests`
cron. On 2026-07-29 the login-ui-d051 e2 rounds' 500s (attributed in
`mobile-device-auth-ip-limit-shared-sections.md` to the live pr-review-d957 section) were actually
held by 5 rows created 04:39-04:46 and expired by 04:56 — 6h stale; no live traffic involved.

Cheap probe (no stack, no device, read-only):
docker exec dev-postgres-1 psql -U postgres -d postgres -c \
"SELECT ip_address,status,count(*),max(expires_at) FROM device_auth_requests GROUP BY 1,2;"
5 pending whose `expires_at` is long past = stuck rows, not a live holder; the consent dialog's
domain ("Kilo Wants to Use <ip> to Sign In") names the limiter IP.

Fix (product's own path, NOT a mock — the limiter still evaluates real DB state for every later
start): once the stuck codes are past expiry, poll each through the real endpoint on your own
stack: `curl http://localhost:<nextjs-port>/api/device-auth/codes/<code>` → 410 expired;
`pollDeviceAuthRequest` flips the row to `expired`. Never UPDATE the table directly. Fresh codes
(<10min old) return 202 and stay pending — reconcile your own flip codes after expiry so the next
section does not inherit your wedge.
18 changes: 18 additions & 0 deletions .kilo_workflow/learnings/mobile-ios-device-auth-consent-sheet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# mobile: iOS device-auth start() opens an ASWebAuthenticationSession consent over the pending branch

Symptom: after tapping `More sign-in options` on the iOS login screen, a system alert
`"Kilo" Wants to Use "<ip>" to Sign In` (Cancel/Continue) covers the app, and the
pending-with-code branch is not visible for pixel probes.

Cause: `use-device-auth.ts` `start()` calls `WebBrowser.openAuthSessionAsync(verificationUrl)` on
iOS (ASWebAuthenticationSession); the consent alert precedes the auth sheet. e1/e2 never hit it
because their starts all 500'd (error branch renders before the browser call).

Fix: tap `Cancel` index 0 on the consent — `openAuthSessionAsync` resolves as cancelled, NO app
state changes (the hook's own `cancel()` is not called), and the pending-with-code branch
("Your sign-in code:", `Sign in code: X X X X - X X X X`, "Open sign-in page in browser",
"Cancel sign in") stays rendered for probing. Then tap `Cancel sign in` to return to idle.
Probe discriminators: dark-glyph fraction on the big code text (full alpha ~0.19-0.21 with the
all-channels<100 threshold; parked ~50% alpha washes glyphs above 100 -> ~0.0) and on the
Open-in-browser button text (~0.036 full alpha). The muted "Your sign-in code:" heading scores
0.000 even when healthy (gray > 100/channel) — not a discriminator.
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# iOS Settings text-size slider: reliable Maestro swipe technique

Symptom: restoring Settings > Display & Brightness > Text Size from 100% (XXXL) back to the
default via Maestro swipe keeps failing — swipes from the right edge (91%/98% starts) and track
taps do not move the thumb.

Cause: at 100% the slider thumb sits at roughly 78% of track width, not at the right edge; a swipe
that starts right of the thumb grabs empty track. Taps on the track do not reposition this slider.

Fix: swipe with unquoted integer percentages (Maestro 2.7.0 rejects quoted values), starting ON
the thumb: `swipe: { start: 78%, 89% ... end: 10%, 89% }` style coordinates (adjust the y to the
slider row), then verify visually with a screenshot — the a11y tree does not expose the slider
value reliably. Same technique drives the slider up to 100% (start at the current thumb position,
e.g. 50% for the L default).
14 changes: 14 additions & 0 deletions .kilo_workflow/learnings/mobile-otp-outbox-race-parallel-phases.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# mobile: parallel device phases on one worktree email race the OTP outbox (latest code wins)

Symptom (login-ui-d051 e2, 2026-07-29): an Android OTP Verify tap reached the backend but got
`POST /api/auth/native/token 401` with the correct-looking 6-digit code. Cause: an iOS Maestro
loop running CONCURRENTLY on the same worktree signed in with the same default email
(`e2e-mobile-<worktree>@example.com`); its newer `POST /api/auth/native/otp` invalidated the
Android device's outstanding code before the Verify tap. The 401 is correct product behavior
for a stale code, not a tap failure.

Fix: when two device phases share one worktree email, either (a) finish every OTP verify before
the next request-code on ANY device, (b) read the outbox code immediately before typing it and
confirm the newest outbox file's timestamp postdates your device's request, or (c) pass an
explicit different email to one phase (`login.sh <device> <email>`). Evidence the tap landed:
the 401 line in `pnpm dev:capture nextjs` — a covered/no-op tap produces NO request line.
Loading
Loading