Skip to content

fix(workflow): unwedge shared E2E infra (Android claims, preflight, tmux targeting, stack cap) - #4826

Merged
iscekic merged 4 commits into
mainfrom
fix/android-e2e-emulator-wedges
Jul 28, 2026
Merged

fix(workflow): unwedge shared E2E infra (Android claims, preflight, tmux targeting, stack cap)#4826
iscekic merged 4 commits into
mainfrom
fix/android-e2e-emulator-wedges

Conversation

@iscekic

@iscekic iscekic commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Agents running the kilo workflow on the mobile app kept losing rounds to Android emulator problems that looked like flaky hardware. They were three deterministic defects. Each one presents as "the emulator is misbehaving" and none of them was recoverable through a documented command, so the agent retried, rebooted, restarted the stack, and burned tokens.

1. Claims wedge a serial forever (the expensive one)

dev:mobile:android claim keyed staleness on "has the claiming worktree been deleted". But ADB serials are recycled ports, not device identities: the next emulator to boot takes emulator-5554 again, and workflow worktrees outlive their runs by days. So a claim left behind by a crashed run pinned that serial to a worktree that still exists — permanently.

Both emulator-5554 and emulator-5556 were wedged this way on the shared machine when I looked, from July 26 and 27, for emulators long gone. Those are the first two serials any emulator boot takes, so the next Android round was guaranteed to hit:

emulator-5554 is claimed by /Users/igor/Projects/.worktrees/cloud-mob-share-target

with no way out — release from another worktree throws the same error, and there is no --force.

Claims now record the guest kernel's boot id (/proc/sys/kernel/random/boot_id), which is regenerated on every boot and stable for the instance's life. A foreign claim holds a serial only while it names the instance currently answering on it; claims from earlier instances, and pre-existing claims with no boot id, are reclaimed automatically. Same-worktree reclaim stays idempotent and refreshes the boot id when the emulator was restarted mid-run. The deleted-worktree rule is unchanged.

2. Preflight's Metro readiness check is iOS-only

preflight.sh accepted iOS Bundled|Starting Metro Bundler as evidence Metro was usable. On Android the bundle lines read Android Bundled, so the only thing that could match was Starting Metro Bundler — which scrolls out of the 300-line capture window on any long-lived stack. Result: Android preflight starts failing with Metro has not reached a usable state the longer a session runs, which is precisely when mid-test recovery (pm clear → rerun login.sh) needs it. iOS never sees this. The manifest probe also asked for the iOS manifest regardless of target platform.

Both now follow the platform.

3. The untargeted tmux session lookup (three callers)

tmux display-message -p '#S' without a target resolves the server's current session — the most recently active one — not the caller's. A dispatcher or agent that is not itself inside tmux therefore gets an arbitrary session name, and a freshly created kilo-e2e-android-* emulator session is the natural attractor. Three places used it:

dispatch-role.sh filed role windows into it. Observed live while investigating: efficient-custom-pool-e6e3-plan-reviewer-r2d and -r2e were both running as windows inside kilo-e2e-android-hermes-mem-c716. The Android runbook's cleanup step is tmux kill-session -t "$ANDROID_SESSION" — so that worktree's device cleanup would have killed two of another section's review agents mid-round, voiding them.

WORKFLOW.md told the starter to launch planners into it, so planner windows land under an unrelated section the same way.

.kilo/agent/e2e-verifier.md used it to name the owner of its device slot, and that is the expensive one. The slot ends up owned by another session's name, so e2e-slot.sh reclaims it when that session dies while the verifier is still driving a device. The machine then runs more than its three concurrent device phases, and every emulator boot and native build competes — which surfaces as boot-envelope timeouts and GPU-policy relaunches, i.e. as flaky emulators rather than as a slot leak.

All three now resolve through $TMUX_PANE, which is unambiguous, and each says what to do when it is unset — own session for a dispatched role and for a planner, test-environment blocker for a verifier — instead of guessing. e2e-slot.sh acquire additionally rejects an owner that is not a live tmux session, so a window name (which the docs already warned against) or a misread name fails loudly instead of booking a slot that is reapable the moment it is written.

4. A dev stack was not capped by anything

The slot semaphore caps concurrent device phases at three. Nothing capped live dev stacks. A slot is released the moment a device phase ends (AGENTS.md: never hold one through planning or review), while pnpm dev:stop only runs when a section reaches COMPLETE or BLOCKED — so a section looping between rounds, wedged, or dead leaves ~17 services up with nothing accounting for them. Found five stacks running against a cap of three, load average 379 on 14 cores (379/508/327 across 1/5/15 min). An emulator that cold-boots in 24s on an idle host cannot make the runbook's 8-minute envelope under that, and the documented response is to blame GPU mode and relaunch — so this presents as flaky emulators too.

A slot and a stack are now one resource in e2e-slot.sh: acquire records the worktree, release stops that worktree's stack unless another held slot still covers it, and reclaiming a dead holder's slot stops its stack as well. status reports each holder's worktree and stack state; stacks [--reap] lists stacks running with no slot and stops the workflow-owned ones. A stack whose name carries no section run id was started by hand and is only ever reported. Coverage falls back to the holder's session-name prefix so slots written before this change never make a live stack look abandoned.

The cost is explicit: a section that releases its slot between rounds pays a stack restart on the next one. That is what makes the cap of three actually hold.

Also

Documents the one-time npx expo prebuild --platform android that build needs in a fresh worktree. apps/mobile/android/ is git-ignored and build deliberately does not generate it (matching iOS), but the runbook never said so, so agents met it as an error that contradicts "never invoke Gradle directly".

Verification

  • dev/local/mobile-android.test.ts — three new cases: recycled serial with a live owning worktree, legacy claim without a boot id, and own-claim refresh across a reboot. Confirmed all three fail against the old staleness rule and pass with the fix; full suite 10/10, mobile-android-build 20/20 together.
  • pnpm typecheck exit 0, pnpm lint 0 warnings 0 errors, pnpm run format:changed, bash -n on both shell scripts.
  • Boot id read verified against the live emulator-5558 (stable across reads).
  • Slot owner guard exercised against the live server: a bogus name and a real window name are both rejected immediately without blocking, a live session name passes the guard and reaches the busy queue, and all three held slots were left untouched.
  • Recovered the misplaced live agent without killing it — tmux move-window relocated plan-reviewer-r2d into its planner session with the same PIDs still running, so no round was voided by the diagnosis.
  • Slot/stack unification exercised against the live server: legacy slots with no recorded worktree still report their stacks as covered; stacks --reap refused both a section-shaped stack with no worktree on disk and a hand-started stack with no run id, and left all three real stacks untouched; slug-to-worktree resolution checked against git worktree list.
  • Stopped the two stacks that held no slot (remote-cli-69f6, attach-oom-11fd), taking the host from five stacks to three and the 1-minute load from 379 to 142.
  • Cleared the two wedged claims on the machine so the next Android round is not blocked before this merges. Left emulator-5558 alone — still connected and in use.

Not verified end to end: a full Android E2E round through this preflight, which would have meant taking the emulator away from the agent currently using it.

One rollout note: a claim written before this change carries no boot id and is reclaimable by a worktree running the fixed code, so an Android device could change hands while a pre-fix round is still live. It self-resolves as running rounds finish, and the alternative was leaving the permanent wedge in place.

Three defects made Android E2E rounds fail for reasons agents could not
diagnose or recover from:

- Android device claims treated an ADB serial as a device identity, but
  serials are recycled emulator ports. A claim was only reclaimable once
  its owning worktree was deleted, and worktrees outlive runs by days, so
  a claim left behind by a dead emulator wedged that serial permanently.
  emulator-5554 and emulator-5556 were both wedged this way on the shared
  machine — the first two serials any emulator boot takes. Claims now
  record the guest kernel boot id, so a foreign claim holds a serial only
  while it names the instance currently answering on it.

- The shared E2E preflight accepted only `iOS Bundled` as Metro readiness
  evidence. On Android the alternative `Starting Metro Bundler` line
  scrolls out of the captured window on a long-lived stack, so preflight
  started failing with "Metro has not reached a usable state" the longer a
  session ran — exactly when mid-test recovery reruns login.sh. It also
  probed the Metro manifest as iOS regardless of the target platform.

- dispatch-role.sh resolved its target with an untargeted
  `tmux display-message -p '#S'`, which answers with the server's current
  session rather than the caller's. A dispatcher outside tmux silently
  dropped role windows into an unrelated session; freshly created
  kilo-e2e-android-* emulator sessions were the usual victim, and device
  cleanup then killed those foreign agents. The session is now resolved
  through $TMUX_PANE, falling back to an own session instead of a guess.

Also documents the one-time `expo prebuild --platform android` that
`build` needs in a fresh worktree, since apps/mobile/android/ is
git-ignored and `build` cannot generate it itself.
@iscekic iscekic self-assigned this Jul 28, 2026
@kilo-code-bot

kilo-code-bot Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Executive Summary

Reviewed the one new commit (fd1ad6399, unifying an E2E device slot with its dev stack) added since the prior review pass; traced the shared-loop-variable pattern between stack_is_covered, stop_stack, and reap/release in e2e-slot.sh and confirmed bash's per-iteration for var in list reassignment prevents the aliasing that pattern would otherwise risk, with no new correctness, security, or reliability issues found in the changed lines.

Files Reviewed (3 files changed since last review)
  • .kilo_workflow/WORKFLOW.md
  • .kilo_workflow/e2e-slot.sh
  • apps/mobile/e2e/AGENTS.md
Previous Review Summaries (2 snapshots, latest commit ac62472)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit ac62472)

Status: No Issues Found | Recommendation: Merge

Executive Summary

Reviewed the two new commits (untargeted-tmux-session doc/script follow-ups and a dispatch-role.sh pane status echo) added since the prior review pass; both are internally consistent with the previously-reviewed claim/preflight/dispatch fixes and introduce no new correctness, security, or reliability issues.

Files Reviewed (4 files changed since last review)
  • .kilo/agent/e2e-verifier.md
  • .kilo_workflow/WORKFLOW.md
  • .kilo_workflow/e2e-slot.sh
  • .kilo_workflow/dispatch-role.sh

Previous review (commit 7e18de1)

Status: No Issues Found | Recommendation: Merge

Executive Summary

Reviewed the Android E2E claim/preflight/dispatch fixes across all six changed files; the boot-id claim reclaim logic, platform-aware Metro readiness check, and $TMUX_PANE-based session resolution are all internally consistent and covered by the new tests, with no high-confidence security, correctness, or reliability issues found in the changed lines.

Files Reviewed (6 files)
  • .kilo_workflow/WORKFLOW.md
  • .kilo_workflow/dispatch-role.sh
  • apps/mobile/e2e/AGENTS.md
  • apps/mobile/e2e/preflight.sh
  • dev/local/mobile-android.test.ts
  • dev/local/mobile-android.ts

Reviewed by claude-sonnet-5 · Input: 26 · Output: 12.3K · Cached: 737K

Review guidance: REVIEW.md from base branch main

@iscekic
iscekic requested a review from jeanduplessis July 28, 2026 13:46
iscekic added 3 commits July 28, 2026 15:50
dispatch-role.sh was only one of three places using
`tmux display-message -p '#S'` without a target, which answers with the
tmux server's current session rather than the caller's:

- WORKFLOW.md told the starter to launch planners into that session, so
  planner windows can be filed under an unrelated section the same way
  role windows were.
- The e2e-verifier definition used it to name the owner of its device
  slot. That is the worst case: the slot ends up owned by another
  session's name and is reclaimed when that session dies while the
  verifier is still driving a device. The machine then over-subscribes
  past its three slots, and concurrent emulator boots and native builds
  start timing out — read as flaky emulators, not as a slot leak.

Both now resolve through `$TMUX_PANE`, and say what to do when it is
unset (own session for the planner; test-environment blocker for the
verifier) instead of guessing a target.

e2e-slot.sh now rejects an owner that is not a live tmux session, so a
window name or a misread session name fails loudly at acquire time
instead of booking a slot that is reapable the moment it is written.
Dispatched role agents redirect stdout and stderr into the scratch log, so
their tmux pane is empty for the whole run and an attached human reads a
live agent as a dead one. Print the log path and the tail command in the
pane before exec'ing kilo. Terminal only — nothing reaches the log, so the
trailing EXITCODE marker and the sentinel contract are unchanged.
A slot capped concurrent device phases; nothing capped live dev stacks.
The slot was released the moment a device phase ended while the stack
stayed up, and `dev:stop` only happened when a section reached a terminal
state — so any section that looped between rounds, wedged, or died left
~17 services running with nothing accounting for them. Five stacks were up
on this 14-core host against a cap of three, load average 379, which is
why emulator boots blew through the 8-minute envelope and read as flaky
devices.

e2e-slot.sh now owns both halves:

- acquire records the worktree that took the slot.
- release frees the slot and stops that worktree's stack, unless another
  held slot still covers it. A later round re-acquires and starts fresh;
  that restart is the price of the cap holding.
- Reclaiming a dead holder's slot stops its stack too, so a section that
  dies mid-flight no longer leaks one forever.
- status reports each holder's worktree and stack state, and warns about
  stacks running with no slot.
- stacks [--reap] lists those and stops the workflow-owned ones. Stacks
  with no section run id in the name were started by hand and are only
  reported, never stopped.

Coverage falls back to the holder's session-name prefix when a slot has no
recorded worktree, so slots written before this change never make a live
stack look abandoned.
@iscekic iscekic changed the title fix(mobile): unwedge Android E2E emulator claims and preflight fix(workflow): unwedge shared E2E infra (Android claims, preflight, tmux targeting, stack cap) Jul 28, 2026
@iscekic
iscekic enabled auto-merge (squash) July 28, 2026 14:18
@iscekic
iscekic merged commit 96154f1 into main Jul 28, 2026
20 checks passed
@iscekic
iscekic deleted the fix/android-e2e-emulator-wedges branch July 28, 2026 14:21
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.

2 participants