From af314b6aa823f7590012c30f5f4dcd5025c19dc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 31 Jul 2026 04:09:57 +0200 Subject: [PATCH 01/19] fix(workflow): harden E2E slot acquire, status, and resource starts Shorten slot acquire to 8 minutes with a one-shot holders line, enumerate booted/unclaimed and claimed-but-dead devices in status, and fold prebuild plus claim/build into ios/android starts with a compact JSON stdout contract and bundle --ios-only. --- .kilo_workflow/.e2e-slot-state.sh | 19 +- .kilo_workflow/e2e-slot-status.sh | 109 +++- .kilo_workflow/e2e-start-resource.sh | 108 ++-- dev/local/workflow-scripts.test.ts | 753 +++++++++++++++++++++++++++ 4 files changed, 954 insertions(+), 35 deletions(-) diff --git a/.kilo_workflow/.e2e-slot-state.sh b/.kilo_workflow/.e2e-slot-state.sh index ea918f87766..326b8370cc1 100755 --- a/.kilo_workflow/.e2e-slot-state.sh +++ b/.kilo_workflow/.e2e-slot-state.sh @@ -5,7 +5,7 @@ set -uo pipefail DIR="$HOME/.cache/kilo-e2e-slots" TOTAL=3 POLL=10 -ACQUIRE_DEADLINE=2700 +ACQUIRE_DEADLINE=480 mkdir -p "$DIR" || { echo "cannot create slot state dir $DIR" >&2; exit 1; } [ -w "$DIR" ] || { echo "slot state dir $DIR is not writable" >&2; exit 1; } command -v tmux >/dev/null || { echo "tmux is required" >&2; exit 1; } @@ -78,6 +78,7 @@ case "${1:?internal usage: $0 acquire|release|status|_held}" in done deadline=$(($(date +%s) + ACQUIRE_DEADLINE)) + wait_announced=0 while :; do reap for number in $(seq 1 "$TOTAL"); do @@ -104,7 +105,21 @@ case "${1:?internal usage: $0 acquire|release|status|_held}" in "$0" status >&2 || true exit 1 fi - echo "all $TOTAL E2E slots busy; retrying in ${POLL}s" >&2 + if [ "$wait_announced" -eq 0 ]; then + holders="" + for slot in "$DIR"/slot-*; do + [ -d "$slot" ] || continue + name=$(basename "$slot") + slot_owner=$(cat "$slot/owner" 2>/dev/null || echo '') + worktree=$(cat "$slot/worktree" 2>/dev/null || echo '') + if [ -n "$holders" ]; then holders="${holders}, "; fi + holders="${holders}${name} ${slot_owner} [${worktree}]" + done + echo "waiting for an E2E slot; holders: ${holders}" >&2 + wait_announced=1 + else + echo "all $TOTAL E2E slots busy; retrying in ${POLL}s" >&2 + fi sleep "$POLL" done ;; diff --git a/.kilo_workflow/e2e-slot-status.sh b/.kilo_workflow/e2e-slot-status.sh index 0bb7fb6d27c..85f59b22f1e 100755 --- a/.kilo_workflow/e2e-slot-status.sh +++ b/.kilo_workflow/e2e-slot-status.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # Reap dead slot owners, list live holders, then report known resources whose -# worktree has no slot. Reporting is read-only and exits 0 — UNACCOUNTED lines -# in the output are the finding; the owning workflow cleans up. Passing any -# argument is a usage error (exit 1). +# worktree has no slot and real booted devices/emulators with no claim record. +# Reporting is read-only and exits 0 — UNACCOUNTED/DEAD lines in the output are +# the findings; the owning workflow or human decides what to stop. set -euo pipefail HERE=$(dirname "$0") "$HERE/.e2e-slot-state.sh" status "$@" @@ -17,6 +17,10 @@ for slot in "$state"/slot-*; do done > "$covered" unaccounted=0 +dead=0 +ios_enum=0 +android_enum=0 + while IFS= read -r session; do # Slug extraction and candidate scheme must match how each session kind was # named: kilo-dev-*/emulator records use per-char underscores, cli sessions @@ -57,6 +61,103 @@ for root in "${TMPDIR:-/tmp}/kilo-mobile-simulator-claims" \ done done -if [ "$unaccounted" -eq 0 ]; then +# Collect booted device ids for claimed-but-dead detection. +ios_booted=$(mktemp) +android_booted=$(mktemp) +dead_printed=$(mktemp) +trap 'rm -f "$covered" "$ios_booted" "$android_booted" "$dead_printed"' EXIT + +if command -v xcrun >/dev/null 2>&1; then + ios_enum=1 + while IFS= read -r line; do + udid=$(printf '%s' "$line" | sed -nE 's/.*\(([^)]+)\)[[:space:]]*\(Booted\).*/\1/p') + [ -n "$udid" ] || continue + printf '%s\n' "$udid" >> "$ios_booted" + if [ ! -f "${TMPDIR:-/tmp}/kilo-mobile-simulator-claims/${udid}.json" ]; then + echo "UNACCOUNTED booted device: $udid (no claim record) — if this is not a manual device, stop it: xcrun simctl shutdown $udid" + unaccounted=1 + fi + done < <(xcrun simctl list devices booted 2>/dev/null || true) +fi + +adb=$(command -v adb 2>/dev/null || true) +if [ -z "$adb" ] && [ -n "${ANDROID_HOME:-}" ] && [ -x "$ANDROID_HOME/platform-tools/adb" ]; then + adb="$ANDROID_HOME/platform-tools/adb" +fi +if [ -z "$adb" ] && [ -x "$HOME/Library/Android/sdk/platform-tools/adb" ]; then + adb="$HOME/Library/Android/sdk/platform-tools/adb" +fi + +if [ -n "$adb" ]; then + android_enum=1 + while IFS= read -r line; do + serial=$(printf '%s' "$line" | awk '/^emulator-/{print $1}') + state_field=$(printf '%s' "$line" | awk '/^emulator-/{print $2}') + [ -n "$serial" ] || continue + [ "$state_field" = "device" ] || continue + printf '%s\n' "$serial" >> "$android_booted" + accounted=0 + for root in "${TMPDIR:-/tmp}/kilo-mobile-android-emulators" \ + "${TMPDIR:-/tmp}/kilo-mobile-android-claims"; do + [ -d "$root" ] || continue + for record in "$root"/*.json; do + [ -f "$record" ] || continue + rec_serial=$(sed -nE 's/.*"serial"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' "$record" | head -1) + [ "$rec_serial" = "$serial" ] && accounted=1 + done + done + if [ "$accounted" -eq 0 ]; then + echo "UNACCOUNTED booted emulator: $serial (no claim/emulator record) — verify ownership before stopping: adb -s $serial emu kill" + unaccounted=1 + fi + done < <("$adb" devices 2>/dev/null || true) +fi + +# Claimed-but-dead detection: records tied to a live slot whose device is gone. +if [ "$ios_enum" -eq 1 ]; then + for root in "${TMPDIR:-/tmp}/kilo-mobile-simulator-claims"; do + [ -d "$root" ] || continue + for record in "$root"/*.json; do + [ -f "$record" ] || continue + worktree=$(sed -nE 's/.*"worktreeRoot"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' "$record" | head -1) + [ -n "$worktree" ] || continue + if grep -qxF -- "$worktree" "$covered" >/dev/null 2>&1; then + id=$(basename "$record" .json) + if ! grep -qxF -- "$id" "$ios_booted" >/dev/null 2>&1; then + if ! grep -qxF -- "$id" "$dead_printed" >/dev/null 2>&1; then + echo "DEAD device: $id claimed by $worktree (slot held) — owner should relaunch or release" + printf '%s\n' "$id" >> "$dead_printed" + dead=1 + fi + fi + fi + done + done +fi + +if [ "$android_enum" -eq 1 ]; then + for root in "${TMPDIR:-/tmp}/kilo-mobile-android-claims" \ + "${TMPDIR:-/tmp}/kilo-mobile-android-emulators"; do + [ -d "$root" ] || continue + for record in "$root"/*.json; do + [ -f "$record" ] || continue + worktree=$(sed -nE 's/.*"worktreeRoot"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' "$record" | head -1) + [ -n "$worktree" ] || continue + if grep -qxF -- "$worktree" "$covered" >/dev/null 2>&1; then + serial=$(sed -nE 's/.*"serial"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' "$record" | head -1) + [ -n "$serial" ] || continue + if ! grep -qxF -- "$serial" "$android_booted" >/dev/null 2>&1; then + if ! grep -qxF -- "$serial" "$dead_printed" >/dev/null 2>&1; then + echo "DEAD device: $serial claimed by $worktree (slot held) — owner should relaunch or release" + printf '%s\n' "$serial" >> "$dead_printed" + dead=1 + fi + fi + fi + done + done +fi + +if [ "$unaccounted" -eq 0 ] && [ "$dead" -eq 0 ]; then echo "no unaccounted known resources" fi diff --git a/.kilo_workflow/e2e-start-resource.sh b/.kilo_workflow/e2e-start-resource.sh index 9570e6296ad..44a6e8c507f 100755 --- a/.kilo_workflow/e2e-start-resource.sh +++ b/.kilo_workflow/e2e-start-resource.sh @@ -7,12 +7,32 @@ HERE=$(dirname "$0") exit 1 } -resource=${1:?usage: $0 stack [targets...] | ios [udid] | android [--gpu ...] [--wait] | bundle [--gpu ...] | command [args...]} +resource=${1:?usage: $0 stack [targets...] | ios [udid] | android [--gpu ...] [--wait] | bundle --ios-only | bundle [--gpu ...] | command [args...]} shift # Run repo wrappers from this script's own repository, never the caller's # CWD — invoked by absolute path from a sibling worktree, pnpm would # otherwise start the wrong stack while the slot record names this one. -cd "$(git -C "$(dirname "$0")" rev-parse --show-toplevel)" +ROOT=$(git -C "$(dirname "$0")" rev-parse --show-toplevel) +cd "$ROOT" + +prebuild_clean() { + local platform snapshot_before snapshot_after + platform=$1 + if [ "$platform" = "ios" ]; then + [ -d "apps/mobile/ios/Kilo.xcworkspace" ] && return 0 + elif [ "$platform" = "android" ]; then + [ -d "apps/mobile/android" ] && return 0 + fi + snapshot_before=$(git status --porcelain) + (cd apps/mobile && CI=1 npx expo prebuild --platform "$platform") >&2 + snapshot_after=$(git status --porcelain) + if [ "$snapshot_before" != "$snapshot_after" ]; then + echo "prebuild for $platform produced new tracked changes; failing so they are not committed:" >&2 + printf '%s\n' "$snapshot_after" >&2 + exit 1 + fi +} + case "$resource" in stack) # Idempotent bindings (a fresh worktree's session-ingest rejects every @@ -52,8 +72,15 @@ NODE } ;; esac ;; - ios) exec pnpm dev:mobile:simulator claim "$@" ;; + ios) + prebuild_clean ios + CLAIM=$(pnpm -s dev:mobile:simulator claim "$@") + UDID=$(printf '%s' "$CLAIM" | node -e 'process.stdout.write(JSON.parse(require("fs").readFileSync(0,"utf8")).device.id)') + pnpm dev:mobile:ios build "$UDID" >&2 + printf '%s\n' "$CLAIM" | node -e 'process.stdout.write(JSON.stringify(JSON.parse(require("fs").readFileSync(0,"utf8"))))' + ;; android) + prebuild_clean android # Idempotent per bundle: reuse a live own emulator, start otherwise. RECORD_DIR="${TMPDIR:-/tmp}/kilo-mobile-android-emulators" SLUG=$(basename "$PWD" | sed 's/[^A-Za-z0-9_-]/_/g') @@ -62,56 +89,79 @@ NODE SERIAL=$(node -e 'process.stdout.write(JSON.parse(require("fs").readFileSync(process.argv[1])).serial)' "$RECORD") if pnpm -s dev:mobile:android adb -s "$SERIAL" shell getprop sys.boot_completed 2>/dev/null | grep -q '^1'; then echo "reusing running emulator $SERIAL ($RECORD)" >&2 - cat "$RECORD" - exit 0 + else + echo "recorded emulator not booted; starting fresh" >&2 + pnpm dev:mobile:android emulator-stop || true + case " $* " in + *" --wait "*) ;; + *) set -- "$@" --wait ;; + esac + pnpm dev:mobile:android emulator-start "$@" >&2 fi - echo "recorded emulator not booted; starting fresh" >&2 - pnpm dev:mobile:android emulator-stop || true + else + case " $* " in + *" --wait "*) ;; + *) set -- "$@" --wait ;; + esac + pnpm dev:mobile:android emulator-start "$@" >&2 fi - case " $* " in - *" --wait "*) ;; - *) set -- "$@" --wait ;; - esac - exec pnpm dev:mobile:android emulator-start "$@" + RECORD_JSON=$(node -e 'process.stdout.write(JSON.stringify(JSON.parse(require("fs").readFileSync(process.argv[1]))))' "$RECORD") + SERIAL=$(node -e 'process.stdout.write(JSON.parse(require("fs").readFileSync(process.argv[1])).serial)' "$RECORD") + pnpm -s dev:mobile:android claim "$SERIAL" >&2 + pnpm dev:mobile:android build "$SERIAL" >&2 + printf '%s\n' "$RECORD_JSON" ;; bundle) # Whole E2E bundle at once: stack ∥ iOS chain ∥ Android chain, one log per # chain. The slowest chain sets the wall time, not the sum of the three. - AVD=${1:?usage: $0 bundle [--gpu ...]} - shift + IOS_ONLY=0 + AVD="" + if [ "${1:-}" = "--ios-only" ]; then + IOS_ONLY=1 + shift + else + AVD=${1:?usage: $0 bundle --ios-only | bundle [--gpu ...]} + shift + fi LOGD=$(mktemp -d "${TMPDIR:-/tmp}/kilo-bundle.XXXXXX") - echo "bundle setup logs: $LOGD" + echo "bundle setup logs: $LOGD" >&2 ( "$HERE/e2e-start-resource.sh" stack mobile cloud-agent-next kiloclaw event-service ) >"$LOGD/stack.log" 2>&1 & P_STACK=$! ( - CLAIM=$("$HERE/e2e-start-resource.sh" ios | tail -1) && - UDID=$(printf '%s' "$CLAIM" | node -e 'process.stdout.write(JSON.parse(require("fs").readFileSync(0,"utf8")).device.id)') && - pnpm dev:mobile:ios build "$UDID" + "$HERE/e2e-start-resource.sh" ios ) >"$LOGD/ios.log" 2>&1 & P_IOS=$! - ( - RECORD=$("$HERE/e2e-start-resource.sh" android "$AVD" "$@") && - SERIAL=$(printf '%s' "$RECORD" | grep -o '"serial": *"[^"]*"' | cut -d'"' -f4) && - [ -n "$SERIAL" ] && - pnpm -s dev:mobile:android claim "$SERIAL" >/dev/null && - pnpm dev:mobile:android build "$SERIAL" - ) >"$LOGD/android.log" 2>&1 & - P_ANDROID=$! + if [ "$IOS_ONLY" -eq 0 ]; then + ( + "$HERE/e2e-start-resource.sh" android "$AVD" "$@" + ) >"$LOGD/android.log" 2>&1 & + P_ANDROID=$! + fi FAILED=0 - for p in $P_STACK $P_IOS $P_ANDROID; do + for p in $P_STACK $P_IOS ${P_ANDROID:-}; do + [ -n "$p" ] || continue wait "$p" || FAILED=1 done [ "$FAILED" -eq 0 ] || { echo "bundle setup failed — inspect $LOGD" >&2 exit 1 } - echo "bundle ready (logs kept at $LOGD)" + # Emit summary lines from the chain logs; failed chains already aborted above. + if [ -s "$LOGD/ios.log" ]; then + ios_line=$(tail -1 "$LOGD/ios.log") + printf 'ios claim: %s\n' "$ios_line" + fi + if [ "$IOS_ONLY" -eq 0 ] && [ -s "$LOGD/android.log" ]; then + android_line=$(tail -1 "$LOGD/android.log") + printf 'android record: %s\n' "$android_line" + fi + echo "bundle ready (logs kept at $LOGD)" >&2 ;; command) [ "$#" -gt 0 ] || { echo "command requires a command to run" >&2; exit 1; } exec "$@" ;; - *) echo "unknown resource '$resource'; expected stack, ios, android, or command" >&2; exit 1 ;; + *) echo "unknown resource '$resource'; expected stack, ios, android, bundle, or command" >&2; exit 1 ;; esac diff --git a/dev/local/workflow-scripts.test.ts b/dev/local/workflow-scripts.test.ts index 2f7bf285de9..9b16d11b960 100644 --- a/dev/local/workflow-scripts.test.ts +++ b/dev/local/workflow-scripts.test.ts @@ -727,3 +727,756 @@ esac fs.rmSync(root, { recursive: true, force: true }); } }); + +function makePnpmStub(bin: string, commandLog: string): void { + executable( + path.join(bin, 'pnpm'), + '#!/bin/sh\n' + + `main="\$1"\n` + + `sub="\$2"\n` + + `if [ "\$main" = "-s" ]; then\n` + + ` main="\$2"\n` + + ` sub="\$3"\n` + + `fi\n` + + `printf '%s: %s\\n' "\$PWD" "\$*" >> "${commandLog.replace(/"/g, '\\"')}"\n` + + `case "\$main" in\n` + + ` dev:env|dev:start|drizzle) ;;\n` + + ` dev:status)\n` + + ` printf '%s\\n' '{"services":[{"name":"mobile","status":"up"},{"name":"nextjs","status":"up"},{"name":"cloudflare-session-ingest","status":"up"},{"name":"cloud-agent-next","status":"up"},{"name":"kiloclaw","status":"up"},{"name":"event-service","status":"up"}]}'\n` + + ` ;;\n` + + ` dev:mobile:simulator)\n` + + ` printf '{"device":{"id":"UDID-123"},"worktreeRoot":"%s"}\\n' "\$PWD"\n` + + ` ;;\n` + + ` dev:mobile:ios)\n` + + ` if [ -n "\$IOS_BUILD_FAIL" ]; then exit 1; fi\n` + + ` ;;\n` + + ` dev:mobile:android)\n` + + ` case "\$sub" in\n` + + ` emulator-start)\n` + + ` mkdir -p "\$TMPDIR/kilo-mobile-android-emulators"\n` + + ` printf '{\\n "serial": "emulator-5554",\\n "pid": 123,\\n "log": "/tmp/log",\\n "worktreeRoot": "%s"\\n}\\n' "\$PWD" > "\$TMPDIR/kilo-mobile-android-emulators/$(basename "\$PWD").json"\n` + + ` ;;\n` + + ` adb)\n` + + ` [ "\$BOOT_COMPLETED" = "1" ] && echo 1 || echo 0\n` + + ` ;;\n` + + ` claim) ;;\n` + + ` build)\n` + + ` if [ -n "\$ANDROID_BUILD_FAIL" ]; then exit 1; fi\n` + + ` ;;\n` + + ` esac\n` + + ` ;;\n` + + `esac\n` + ); +} + +function makeNpxStub(bin: string, commandLog: string): void { + executable( + path.join(bin, 'npx'), + '#!/bin/sh\n' + + `printf '%s: %s\\n' "\$PWD" "\$*" >> "${commandLog.replace(/"/g, '\\"')}"\n` + + `if [ "\$1" = "expo" ] && [ "\$2" = "prebuild" ]; then\n` + + ` platform="\$4"\n` + + ` if [ "\$platform" = "ios" ]; then\n` + + ` mkdir -p ios/Kilo.xcworkspace\n` + + ` elif [ "\$platform" = "android" ]; then\n` + + ` mkdir -p android\n` + + ` touch android/build.gradle\n` + + ` fi\n` + + ` if [ -n "\$DIRTY_PREBUILD" ]; then\n` + + ` touch dirty-prebuild.txt\n` + + ` fi\n` + + `fi\n` + ); +} + +test('E2E status enumerates iOS booted devices and Android emulators', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-e2e-device-enum-test-')); + const bin = path.join(root, 'bin'); + const home = path.join(root, 'home'); + const slot = path.join(home, '.cache/kilo-e2e-slots/slot-1'); + fs.mkdirSync(bin); + fs.mkdirSync(slot, { recursive: true }); + fs.writeFileSync(path.join(slot, 'owner'), 'live-owner'); + fs.writeFileSync(path.join(slot, 'worktree'), path.join(root, 'covered-abcd')); + fs.writeFileSync(path.join(slot, 'since'), '2026-01-01T00:00:00Z\n'); + fs.writeFileSync( + path.join(bin, 'tmux'), + '#!/bin/sh\n' + + 'if [ "$1" = has-session ]; then exit 0; fi\n' + + 'if [ "$1" = list-sessions ]; then printf "kilo-dev-covered-abcd\n"; exit 0; fi\n' + + 'exit 1\n' + ); + fs.writeFileSync( + path.join(bin, 'xcrun'), + '#!/bin/sh\n' + + 'if [ "$1" = "simctl" ] && [ "$2" = "list" ] && [ "$3" = "devices" ] && [ "$4" = "booted" ]; then\n' + + ' printf "iPhone 17 (BOOTED-UDID) (Booted)\\n"\n' + + ' printf "iPhone Claimed (CLAIMED-UDID) (Booted)\\n"\n' + + 'fi\n' + ); + fs.writeFileSync( + path.join(bin, 'adb'), + '#!/bin/sh\n' + + 'printf "List of devices attached\\nemulator-5554\\tdevice\\nphysical-123\\tdevice\\n"\n' + ); + for (const command of ['tmux', 'xcrun', 'adb']) fs.chmodSync(path.join(bin, command), 0o755); + try { + const result = spawnSync(path.join(repoRoot, '.kilo_workflow/e2e-slot-status.sh'), [], { + encoding: 'utf8', + env: { + ...process.env, + HOME: home, + PATH: `${bin}:${process.env.PATH}`, + TMPDIR: path.join(root, 'tmp'), + }, + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /UNACCOUNTED booted device: BOOTED-UDID/); + assert.match(result.stdout, /UNACCOUNTED booted emulator: emulator-5554/); + assert.doesNotMatch(result.stdout, /UNACCOUNTED booted emulator: physical-123/); + // Claimed iOS device is covered by the existing claim check, not device enum. + fs.mkdirSync(path.join(root, 'tmp', 'kilo-mobile-simulator-claims'), { recursive: true }); + fs.writeFileSync( + path.join(root, 'tmp', 'kilo-mobile-simulator-claims', 'CLAIMED-UDID.json'), + JSON.stringify({ worktreeRoot: path.join(root, 'covered-abcd') }) + ); + const claimed = spawnSync(path.join(repoRoot, '.kilo_workflow/e2e-slot-status.sh'), [], { + encoding: 'utf8', + env: { + ...process.env, + HOME: home, + PATH: `${bin}:${process.env.PATH}`, + TMPDIR: path.join(root, 'tmp'), + }, + }); + assert.equal(claimed.status, 0, claimed.stderr); + assert.match(claimed.stdout, /UNACCOUNTED booted device: BOOTED-UDID/); + assert.doesNotMatch(claimed.stdout, /UNACCOUNTED booted device: CLAIMED-UDID/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('E2E status reports claimed-but-dead devices and suppresses footer on findings', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-e2e-dead-device-test-')); + const bin = path.join(root, 'bin'); + const home = path.join(root, 'home'); + const slot = path.join(home, '.cache/kilo-e2e-slots/slot-1'); + const covered = path.join(root, 'covered-abcd'); + fs.mkdirSync(bin); + fs.mkdirSync(slot, { recursive: true }); + fs.writeFileSync(path.join(slot, 'owner'), 'live-owner'); + fs.writeFileSync(path.join(slot, 'worktree'), covered); + fs.writeFileSync(path.join(slot, 'since'), '2026-01-01T00:00:00Z\n'); + fs.writeFileSync( + path.join(bin, 'tmux'), + '#!/bin/sh\n' + + 'if [ "$1" = has-session ]; then exit 0; fi\n' + + 'if [ "$1" = list-sessions ]; then printf "kilo-dev-covered-abcd\n"; exit 0; fi\n' + + 'exit 1\n' + ); + fs.writeFileSync( + path.join(bin, 'xcrun'), + '#!/bin/sh\n' + + 'if [ "$1" = "simctl" ] && [ "$2" = "list" ] && [ "$3" = "devices" ] && [ "$4" = "booted" ]; then\n' + + ' printf "iPhone 17 (ALIVE-UDID) (Booted)\\n"\n' + + 'fi\n' + ); + fs.writeFileSync(path.join(bin, 'adb'), '#!/bin/sh\nexit 0\n'); + for (const command of ['tmux', 'xcrun', 'adb']) fs.chmodSync(path.join(bin, command), 0o755); + fs.mkdirSync(path.join(root, 'tmp', 'kilo-mobile-simulator-claims'), { recursive: true }); + fs.writeFileSync( + path.join(root, 'tmp', 'kilo-mobile-simulator-claims', 'DEAD-UDID.json'), + JSON.stringify({ worktreeRoot: covered }) + ); + try { + const result = spawnSync(path.join(repoRoot, '.kilo_workflow/e2e-slot-status.sh'), [], { + encoding: 'utf8', + env: { + ...process.env, + HOME: home, + PATH: `${bin}:${process.env.PATH}`, + TMPDIR: path.join(root, 'tmp'), + }, + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /DEAD device: DEAD-UDID claimed by .*covered-abcd.* \(slot held\)/); + assert.doesNotMatch(result.stdout, /no unaccounted known resources/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('E2E status prints all-clear footer only when there are zero findings', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-e2e-clear-footer-test-')); + const bin = path.join(root, 'bin'); + const home = path.join(root, 'home'); + const slot = path.join(home, '.cache/kilo-e2e-slots/slot-1'); + fs.mkdirSync(bin); + fs.mkdirSync(slot, { recursive: true }); + fs.writeFileSync(path.join(slot, 'owner'), 'live-owner'); + fs.writeFileSync(path.join(slot, 'worktree'), path.join(root, 'covered-abcd')); + fs.writeFileSync(path.join(slot, 'since'), '2026-01-01T00:00:00Z\n'); + fs.writeFileSync( + path.join(bin, 'tmux'), + '#!/bin/sh\n' + + 'if [ "$1" = has-session ]; then exit 0; fi\n' + + 'if [ "$1" = list-sessions ]; then printf "kilo-dev-covered-abcd\n"; exit 0; fi\n' + + 'exit 1\n' + ); + fs.writeFileSync(path.join(bin, 'xcrun'), '#!/bin/sh\nexit 0\n'); + fs.writeFileSync(path.join(bin, 'adb'), '#!/bin/sh\nexit 0\n'); + for (const command of ['tmux', 'xcrun', 'adb']) fs.chmodSync(path.join(bin, command), 0o755); + try { + const result = spawnSync(path.join(repoRoot, '.kilo_workflow/e2e-slot-status.sh'), [], { + encoding: 'utf8', + env: { + ...process.env, + HOME: home, + PATH: `${bin}:${process.env.PATH}`, + TMPDIR: path.join(root, 'tmp'), + }, + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /no unaccounted known resources/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('E2E status reports Android DEAD once when adb sees no devices', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-e2e-android-dead-test-')); + const bin = path.join(root, 'bin'); + const home = path.join(root, 'home'); + const slot = path.join(home, '.cache/kilo-e2e-slots/slot-1'); + const covered = path.join(root, 'covered-abcd'); + fs.mkdirSync(bin); + fs.mkdirSync(slot, { recursive: true }); + fs.writeFileSync(path.join(slot, 'owner'), 'live-owner'); + fs.writeFileSync(path.join(slot, 'worktree'), covered); + fs.writeFileSync(path.join(slot, 'since'), '2026-01-01T00:00:00Z\n'); + fs.writeFileSync( + path.join(bin, 'tmux'), + '#!/bin/sh\n' + + 'if [ "$1" = has-session ]; then exit 0; fi\n' + + 'if [ "$1" = list-sessions ]; then printf "kilo-dev-covered-abcd\n"; exit 0; fi\n' + + 'exit 1\n' + ); + fs.writeFileSync(path.join(bin, 'adb'), '#!/bin/sh\nprintf "List of devices attached\\n"\n'); + for (const command of ['tmux', 'adb']) fs.chmodSync(path.join(bin, command), 0o755); + fs.mkdirSync(path.join(root, 'tmp', 'kilo-mobile-android-claims'), { recursive: true }); + fs.writeFileSync( + path.join(root, 'tmp', 'kilo-mobile-android-claims', 'emulator-5554.json'), + JSON.stringify({ serial: 'emulator-5554', worktreeRoot: covered }) + ); + fs.mkdirSync(path.join(root, 'tmp', 'kilo-mobile-android-emulators'), { recursive: true }); + fs.writeFileSync( + path.join(root, 'tmp', 'kilo-mobile-android-emulators', 'covered-abcd.json'), + JSON.stringify({ serial: 'emulator-5554', worktreeRoot: covered }) + ); + try { + const result = spawnSync(path.join(repoRoot, '.kilo_workflow/e2e-slot-status.sh'), [], { + encoding: 'utf8', + env: { + ...process.env, + HOME: home, + PATH: `${bin}:${process.env.PATH}`, + TMPDIR: path.join(root, 'tmp'), + }, + }); + assert.equal(result.status, 0, result.stderr); + const deadMatches = result.stdout.match(/DEAD device: emulator-5554/g); + assert.equal(deadMatches?.length, 1, `expected one DEAD line, got: ${result.stdout}`); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('E2E acquire wait announces holders before the first retry', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-e2e-acquire-wait-test-')); + const bin = path.join(root, 'bin'); + const home = path.join(root, 'home'); + const state = path.join(home, '.cache/kilo-e2e-slots'); + fs.mkdirSync(bin); + fs.mkdirSync(state, { recursive: true }); + for (let i = 1; i <= 3; i++) { + const slot = path.join(state, `slot-${i}`); + fs.mkdirSync(slot); + fs.writeFileSync(path.join(slot, 'owner'), 'other-owner'); + fs.writeFileSync(path.join(slot, 'worktree'), `/worktree-${i}`); + fs.writeFileSync(path.join(slot, 'since'), '2026-01-01T00:00:00Z\n'); + } + fs.writeFileSync( + path.join(bin, 'tmux'), + '#!/bin/sh\n' + + 'if [ "$1" = display-message ]; then echo test-owner; exit 0; fi\n' + + 'if [ "$1" = has-session ]; then exit 0; fi\n' + + 'exit 1\n' + ); + fs.writeFileSync(path.join(bin, 'sleep'), '#!/bin/sh\nexit 0\n'); + for (const command of ['tmux', 'sleep']) fs.chmodSync(path.join(bin, command), 0o755); + try { + const child = spawnSync(path.join(repoRoot, '.kilo_workflow/.e2e-slot-state.sh'), ['acquire'], { + encoding: 'utf8', + env: { + ...process.env, + HOME: home, + PATH: `${bin}:${process.env.PATH}`, + TMUX_PANE: '%1', + }, + timeout: 5000, + }); + const waitIndex = child.stderr.indexOf('waiting for an E2E slot; holders:'); + const retryIndex = child.stderr.indexOf('retrying in'); + assert.ok(waitIndex >= 0, child.stderr); + assert.ok(retryIndex < 0 || waitIndex < retryIndex, `wait not before retry: ${child.stderr}`); + assert.match(child.stderr, /slot-1 other-owner \[\/worktree-1\]/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('E2E start-resource iOS prebuilds, builds, and emits one compact claim JSON', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-e2e-ios-start-test-')); + const bin = path.join(root, 'bin'); + const home = path.join(root, 'home'); + const commandLog = path.join(root, 'commands.log'); + const slot = path.join(home, '.cache/kilo-e2e-slots/slot-1'); + fs.mkdirSync(bin); + fs.mkdirSync(home); + fs.mkdirSync(slot, { recursive: true }); + fs.writeFileSync(path.join(slot, 'owner'), 'test-owner'); + fs.writeFileSync(path.join(slot, 'worktree'), root); + fs.writeFileSync(path.join(slot, 'since'), '2026-01-01T00:00:00Z\n'); + fs.writeFileSync( + path.join(bin, 'tmux'), + '#!/bin/sh\nif [ "$1" = display-message ]; then echo test-owner; exit 0; fi\nexit 1\n' + ); + fs.chmodSync(path.join(bin, 'tmux'), 0o755); + makePnpmStub(bin, commandLog); + makeNpxStub(bin, commandLog); + const wt = path.join(root, 'wt'); + fs.mkdirSync(path.join(wt, '.kilo_workflow'), { recursive: true }); + fs.mkdirSync(path.join(wt, 'apps/mobile'), { recursive: true }); + fs.mkdirSync(path.join(root, 'tmp'), { recursive: true }); + fs.copyFileSync( + path.join(repoRoot, '.kilo_workflow/.e2e-slot-state.sh'), + path.join(wt, '.kilo_workflow/.e2e-slot-state.sh') + ); + fs.copyFileSync( + path.join(repoRoot, '.kilo_workflow/e2e-start-resource.sh'), + path.join(wt, '.kilo_workflow/e2e-start-resource.sh') + ); + fs.chmodSync(path.join(wt, '.kilo_workflow/.e2e-slot-state.sh'), 0o755); + fs.chmodSync(path.join(wt, '.kilo_workflow/e2e-start-resource.sh'), 0o755); + execFileSync('git', ['init', '-q'], { cwd: wt }); + execFileSync('git', ['-C', wt, 'config', 'user.email', 'test@example.com']); + execFileSync('git', ['-C', wt, 'config', 'user.name', 'Test']); + fs.writeFileSync( + path.join(wt, '.gitignore'), + '.env.local\napps/mobile/ios\napps/mobile/android\n' + ); + execFileSync('git', ['-C', wt, 'add', '.']); + execFileSync('git', ['-C', wt, 'commit', '-qm', 'base']); + try { + const held = spawnSync(path.join(wt, '.kilo_workflow/.e2e-slot-state.sh'), ['_held'], { + encoding: 'utf8', + env: { + ...process.env, + HOME: home, + PATH: `${bin}:${process.env.PATH}`, + TMUX_PANE: '%1', + }, + }); + assert.equal( + held.status, + 0, + `held failed: ${held.stderr} slot=${slot} owner=${fs.readFileSync(path.join(slot, 'owner'), 'utf8')}` + ); + const result = spawnSync(path.join(wt, '.kilo_workflow/e2e-start-resource.sh'), ['ios'], { + encoding: 'utf8', + env: { + ...process.env, + HOME: home, + PATH: `${bin}:${process.env.PATH}`, + TMPDIR: path.join(root, 'tmp'), + TMUX_PANE: '%1', + }, + }); + assert.equal(result.status, 0, result.stderr); + const lines = result.stdout.split('\n').filter(Boolean); + assert.equal(lines.length, 1, `expected single stdout line, got: ${result.stdout}`); + const parsed = JSON.parse(lines[0]); + assert.equal(parsed.device.id, 'UDID-123'); + assert.doesNotMatch(result.stdout, /\n.*build/); + assert.match(fs.readFileSync(commandLog, 'utf8'), /expo prebuild --platform ios/); + assert.match(fs.readFileSync(commandLog, 'utf8'), /dev:mobile:ios build UDID-123/); + // Second invocation reuses the existing prebuild, so npx is not invoked again. + fs.writeFileSync(commandLog, ''); + const reuse = spawnSync(path.join(wt, '.kilo_workflow/e2e-start-resource.sh'), ['ios'], { + encoding: 'utf8', + env: { + ...process.env, + HOME: home, + PATH: `${bin}:${process.env.PATH}`, + TMPDIR: path.join(root, 'tmp'), + TMUX_PANE: '%1', + }, + }); + assert.equal(reuse.status, 0, reuse.stderr); + assert.doesNotMatch(fs.readFileSync(commandLog, 'utf8'), /expo prebuild/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('E2E start-resource android starts, claims, builds, and emits one compact record JSON', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-e2e-android-start-test-')); + const bin = path.join(root, 'bin'); + const home = path.join(root, 'home'); + const commandLog = path.join(root, 'commands.log'); + const slot = path.join(home, '.cache/kilo-e2e-slots/slot-1'); + fs.mkdirSync(bin); + fs.mkdirSync(home); + fs.mkdirSync(slot, { recursive: true }); + fs.writeFileSync(path.join(slot, 'owner'), 'test-owner'); + fs.writeFileSync(path.join(slot, 'worktree'), root); + fs.writeFileSync(path.join(slot, 'since'), '2026-01-01T00:00:00Z\n'); + fs.writeFileSync( + path.join(bin, 'tmux'), + '#!/bin/sh\nif [ "$1" = display-message ]; then echo test-owner; exit 0; fi\nexit 1\n' + ); + fs.chmodSync(path.join(bin, 'tmux'), 0o755); + makePnpmStub(bin, commandLog); + makeNpxStub(bin, commandLog); + const wt = path.join(root, 'wt'); + fs.mkdirSync(path.join(wt, '.kilo_workflow'), { recursive: true }); + fs.mkdirSync(path.join(wt, 'apps/mobile'), { recursive: true }); + fs.mkdirSync(path.join(root, 'tmp'), { recursive: true }); + fs.copyFileSync( + path.join(repoRoot, '.kilo_workflow/.e2e-slot-state.sh'), + path.join(wt, '.kilo_workflow/.e2e-slot-state.sh') + ); + fs.copyFileSync( + path.join(repoRoot, '.kilo_workflow/e2e-start-resource.sh'), + path.join(wt, '.kilo_workflow/e2e-start-resource.sh') + ); + fs.chmodSync(path.join(wt, '.kilo_workflow/.e2e-slot-state.sh'), 0o755); + fs.chmodSync(path.join(wt, '.kilo_workflow/e2e-start-resource.sh'), 0o755); + execFileSync('git', ['init', '-q'], { cwd: wt }); + execFileSync('git', ['-C', wt, 'config', 'user.email', 'test@example.com']); + execFileSync('git', ['-C', wt, 'config', 'user.name', 'Test']); + fs.writeFileSync( + path.join(wt, '.gitignore'), + '.env.local\napps/mobile/ios\napps/mobile/android\n' + ); + execFileSync('git', ['-C', wt, 'add', '.']); + execFileSync('git', ['-C', wt, 'commit', '-qm', 'base']); + try { + const result = spawnSync( + path.join(wt, '.kilo_workflow/e2e-start-resource.sh'), + ['android', 'Pixel_8'], + { + encoding: 'utf8', + env: { + ...process.env, + HOME: home, + PATH: `${bin}:${process.env.PATH}`, + TMPDIR: path.join(root, 'tmp'), + TMUX_PANE: '%1', + }, + } + ); + assert.equal(result.status, 0, result.stderr); + const lines = result.stdout.split('\n').filter(Boolean); + assert.equal(lines.length, 1, `expected single stdout line, got: ${result.stdout}`); + const parsed = JSON.parse(lines[0]); + assert.equal(parsed.serial, 'emulator-5554'); + assert.match(fs.readFileSync(commandLog, 'utf8'), /expo prebuild --platform android/); + assert.match(fs.readFileSync(commandLog, 'utf8'), /dev:mobile:android emulator-start/); + assert.match(fs.readFileSync(commandLog, 'utf8'), /dev:mobile:android claim emulator-5554/); + assert.match(fs.readFileSync(commandLog, 'utf8'), /dev:mobile:android build emulator-5554/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('E2E start-resource android reuse skips emulator-start but still claims and builds', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-e2e-android-reuse-test-')); + const bin = path.join(root, 'bin'); + const home = path.join(root, 'home'); + const commandLog = path.join(root, 'commands.log'); + const slot = path.join(home, '.cache/kilo-e2e-slots/slot-1'); + fs.mkdirSync(bin); + fs.mkdirSync(home); + fs.mkdirSync(slot, { recursive: true }); + fs.writeFileSync(path.join(slot, 'owner'), 'test-owner'); + fs.writeFileSync(path.join(slot, 'worktree'), root); + fs.writeFileSync(path.join(slot, 'since'), '2026-01-01T00:00:00Z\n'); + fs.writeFileSync( + path.join(bin, 'tmux'), + '#!/bin/sh\nif [ "$1" = display-message ]; then echo test-owner; exit 0; fi\nexit 1\n' + ); + fs.chmodSync(path.join(bin, 'tmux'), 0o755); + makePnpmStub(bin, commandLog); + makeNpxStub(bin, commandLog); + const wt = path.join(root, 'wt'); + fs.mkdirSync(path.join(wt, '.kilo_workflow'), { recursive: true }); + fs.mkdirSync(path.join(wt, 'apps/mobile'), { recursive: true }); + fs.mkdirSync(path.join(root, 'tmp'), { recursive: true }); + fs.copyFileSync( + path.join(repoRoot, '.kilo_workflow/.e2e-slot-state.sh'), + path.join(wt, '.kilo_workflow/.e2e-slot-state.sh') + ); + fs.copyFileSync( + path.join(repoRoot, '.kilo_workflow/e2e-start-resource.sh'), + path.join(wt, '.kilo_workflow/e2e-start-resource.sh') + ); + fs.chmodSync(path.join(wt, '.kilo_workflow/.e2e-slot-state.sh'), 0o755); + fs.chmodSync(path.join(wt, '.kilo_workflow/e2e-start-resource.sh'), 0o755); + execFileSync('git', ['init', '-q'], { cwd: wt }); + execFileSync('git', ['-C', wt, 'config', 'user.email', 'test@example.com']); + execFileSync('git', ['-C', wt, 'config', 'user.name', 'Test']); + fs.writeFileSync( + path.join(wt, '.gitignore'), + '.env.local\napps/mobile/ios\napps/mobile/android\n' + ); + execFileSync('git', ['-C', wt, 'add', '.']); + execFileSync('git', ['-C', wt, 'commit', '-qm', 'base']); + const recordDir = path.join(root, 'tmp', 'kilo-mobile-android-emulators'); + fs.mkdirSync(recordDir, { recursive: true }); + fs.writeFileSync( + path.join(recordDir, `${path.basename(wt)}.json`), + JSON.stringify( + { serial: 'emulator-5554', pid: 123, log: '/tmp/log', worktreeRoot: wt }, + null, + 2 + ) + ); + try { + const result = spawnSync( + path.join(wt, '.kilo_workflow/e2e-start-resource.sh'), + ['android', 'Pixel_8'], + { + encoding: 'utf8', + env: { + ...process.env, + HOME: home, + PATH: `${bin}:${process.env.PATH}`, + TMPDIR: path.join(root, 'tmp'), + TMUX_PANE: '%1', + BOOT_COMPLETED: '1', + }, + } + ); + assert.equal(result.status, 0, result.stderr); + const lines = result.stdout.split('\n').filter(Boolean); + assert.equal(lines.length, 1); + const parsed = JSON.parse(lines[0]); + assert.equal(parsed.serial, 'emulator-5554'); + assert.doesNotMatch(fs.readFileSync(commandLog, 'utf8'), /dev:mobile:android emulator-start/); + assert.match(fs.readFileSync(commandLog, 'utf8'), /dev:mobile:android claim emulator-5554/); + assert.match(fs.readFileSync(commandLog, 'utf8'), /dev:mobile:android build emulator-5554/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('E2E start-resource rejects a prebuild that creates tracked changes', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-e2e-prebuild-dirty-test-')); + const bin = path.join(root, 'bin'); + const home = path.join(root, 'home'); + const commandLog = path.join(root, 'commands.log'); + const slot = path.join(home, '.cache/kilo-e2e-slots/slot-1'); + fs.mkdirSync(bin); + fs.mkdirSync(home); + fs.mkdirSync(slot, { recursive: true }); + fs.writeFileSync(path.join(slot, 'owner'), 'test-owner'); + fs.writeFileSync(path.join(slot, 'worktree'), root); + fs.writeFileSync(path.join(slot, 'since'), '2026-01-01T00:00:00Z\n'); + fs.writeFileSync( + path.join(bin, 'tmux'), + '#!/bin/sh\nif [ "$1" = display-message ]; then echo test-owner; exit 0; fi\nexit 1\n' + ); + fs.chmodSync(path.join(bin, 'tmux'), 0o755); + makePnpmStub(bin, commandLog); + makeNpxStub(bin, commandLog); + const wt = path.join(root, 'wt'); + fs.mkdirSync(path.join(wt, '.kilo_workflow'), { recursive: true }); + fs.mkdirSync(path.join(wt, 'apps/mobile'), { recursive: true }); + fs.mkdirSync(path.join(root, 'tmp'), { recursive: true }); + fs.copyFileSync( + path.join(repoRoot, '.kilo_workflow/.e2e-slot-state.sh'), + path.join(wt, '.kilo_workflow/.e2e-slot-state.sh') + ); + fs.copyFileSync( + path.join(repoRoot, '.kilo_workflow/e2e-start-resource.sh'), + path.join(wt, '.kilo_workflow/e2e-start-resource.sh') + ); + fs.chmodSync(path.join(wt, '.kilo_workflow/.e2e-slot-state.sh'), 0o755); + fs.chmodSync(path.join(wt, '.kilo_workflow/e2e-start-resource.sh'), 0o755); + execFileSync('git', ['init', '-q'], { cwd: wt }); + execFileSync('git', ['-C', wt, 'config', 'user.email', 'test@example.com']); + execFileSync('git', ['-C', wt, 'config', 'user.name', 'Test']); + // apps/mobile/ios is NOT ignored, so creating it counts as a tracked change. + fs.writeFileSync(path.join(wt, '.gitignore'), '.env.local\napps/mobile/android\n'); + execFileSync('git', ['-C', wt, 'add', '.']); + execFileSync('git', ['-C', wt, 'commit', '-qm', 'base']); + try { + const result = spawnSync(path.join(wt, '.kilo_workflow/e2e-start-resource.sh'), ['ios'], { + encoding: 'utf8', + env: { + ...process.env, + HOME: home, + PATH: `${bin}:${process.env.PATH}`, + TMPDIR: path.join(root, 'tmp'), + TMUX_PANE: '%1', + DIRTY_PREBUILD: '1', + }, + }); + assert.notEqual(result.status, 0, `expected failure, got: ${result.stderr}`); + assert.match(result.stderr, /prebuild for ios produced new tracked changes/); + assert.equal(result.stdout.trim(), ''); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('E2E start-resource bundle --ios-only emits only ios summary lines', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-e2e-bundle-ios-only-test-')); + const bin = path.join(root, 'bin'); + const home = path.join(root, 'home'); + const commandLog = path.join(root, 'commands.log'); + const slot = path.join(home, '.cache/kilo-e2e-slots/slot-1'); + fs.mkdirSync(bin); + fs.mkdirSync(home); + fs.mkdirSync(slot, { recursive: true }); + fs.writeFileSync(path.join(slot, 'owner'), 'test-owner'); + fs.writeFileSync(path.join(slot, 'worktree'), root); + fs.writeFileSync(path.join(slot, 'since'), '2026-01-01T00:00:00Z\n'); + fs.writeFileSync( + path.join(bin, 'tmux'), + '#!/bin/sh\nif [ "$1" = display-message ]; then echo test-owner; exit 0; fi\nexit 1\n' + ); + fs.chmodSync(path.join(bin, 'tmux'), 0o755); + makePnpmStub(bin, commandLog); + makeNpxStub(bin, commandLog); + fs.writeFileSync( + path.join(bin, 'curl'), + '#!/bin/sh\n' + + 'if [ "$1" = "-sf" ] && [ "$4" = "http://127.0.0.1:23750/v1.44/_ping" ]; then exit 0; fi\n' + + 'exit 1\n' + ); + fs.chmodSync(path.join(bin, 'curl'), 0o755); + const wt = path.join(root, 'wt'); + fs.mkdirSync(path.join(wt, '.kilo_workflow'), { recursive: true }); + fs.mkdirSync(path.join(wt, 'apps/mobile'), { recursive: true }); + fs.mkdirSync(path.join(root, 'tmp'), { recursive: true }); + fs.copyFileSync( + path.join(repoRoot, '.kilo_workflow/.e2e-slot-state.sh'), + path.join(wt, '.kilo_workflow/.e2e-slot-state.sh') + ); + fs.copyFileSync( + path.join(repoRoot, '.kilo_workflow/e2e-start-resource.sh'), + path.join(wt, '.kilo_workflow/e2e-start-resource.sh') + ); + fs.chmodSync(path.join(wt, '.kilo_workflow/.e2e-slot-state.sh'), 0o755); + fs.chmodSync(path.join(wt, '.kilo_workflow/e2e-start-resource.sh'), 0o755); + execFileSync('git', ['init', '-q'], { cwd: wt }); + execFileSync('git', ['-C', wt, 'config', 'user.email', 'test@example.com']); + execFileSync('git', ['-C', wt, 'config', 'user.name', 'Test']); + fs.writeFileSync( + path.join(wt, '.gitignore'), + '.env.local\napps/mobile/ios\napps/mobile/android\n' + ); + execFileSync('git', ['-C', wt, 'add', '.']); + execFileSync('git', ['-C', wt, 'commit', '-qm', 'base']); + try { + const result = spawnSync( + path.join(wt, '.kilo_workflow/e2e-start-resource.sh'), + ['bundle', '--ios-only'], + { + encoding: 'utf8', + env: { + ...process.env, + HOME: home, + PATH: `${bin}:${process.env.PATH}`, + TMPDIR: path.join(root, 'tmp'), + TMUX_PANE: '%1', + }, + } + ); + assert.equal(result.status, 0, result.stderr); + const stdoutLines = result.stdout.split('\n').filter(Boolean); + assert.equal(stdoutLines.length, 1); + assert.match(stdoutLines[0], /^ios claim: /); + const json = stdoutLines[0].replace(/^ios claim: /, ''); + assert.equal(JSON.parse(json).device.id, 'UDID-123'); + assert.doesNotMatch(result.stdout, /android record:/); + assert.doesNotMatch(fs.readFileSync(commandLog, 'utf8'), /dev:mobile:android emulator-start/); + assert.match(result.stderr, /bundle ready/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('E2E start-resource build failure exits non-zero with no stdout JSON', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-e2e-build-fail-test-')); + const bin = path.join(root, 'bin'); + const home = path.join(root, 'home'); + const commandLog = path.join(root, 'commands.log'); + const slot = path.join(home, '.cache/kilo-e2e-slots/slot-1'); + fs.mkdirSync(bin); + fs.mkdirSync(home); + fs.mkdirSync(slot, { recursive: true }); + fs.writeFileSync(path.join(slot, 'owner'), 'test-owner'); + fs.writeFileSync(path.join(slot, 'worktree'), root); + fs.writeFileSync(path.join(slot, 'since'), '2026-01-01T00:00:00Z\n'); + fs.writeFileSync( + path.join(bin, 'tmux'), + '#!/bin/sh\nif [ "$1" = display-message ]; then echo test-owner; exit 0; fi\nexit 1\n' + ); + fs.chmodSync(path.join(bin, 'tmux'), 0o755); + makePnpmStub(bin, commandLog); + makeNpxStub(bin, commandLog); + const wt = path.join(root, 'wt'); + fs.mkdirSync(path.join(wt, '.kilo_workflow'), { recursive: true }); + fs.mkdirSync(path.join(wt, 'apps/mobile'), { recursive: true }); + fs.mkdirSync(path.join(root, 'tmp'), { recursive: true }); + fs.copyFileSync( + path.join(repoRoot, '.kilo_workflow/.e2e-slot-state.sh'), + path.join(wt, '.kilo_workflow/.e2e-slot-state.sh') + ); + fs.copyFileSync( + path.join(repoRoot, '.kilo_workflow/e2e-start-resource.sh'), + path.join(wt, '.kilo_workflow/e2e-start-resource.sh') + ); + fs.chmodSync(path.join(wt, '.kilo_workflow/.e2e-slot-state.sh'), 0o755); + fs.chmodSync(path.join(wt, '.kilo_workflow/e2e-start-resource.sh'), 0o755); + execFileSync('git', ['init', '-q'], { cwd: wt }); + execFileSync('git', ['-C', wt, 'config', 'user.email', 'test@example.com']); + execFileSync('git', ['-C', wt, 'config', 'user.name', 'Test']); + fs.writeFileSync( + path.join(wt, '.gitignore'), + '.env.local\napps/mobile/ios\napps/mobile/android\n' + ); + execFileSync('git', ['-C', wt, 'add', '.']); + execFileSync('git', ['-C', wt, 'commit', '-qm', 'base']); + try { + const result = spawnSync(path.join(wt, '.kilo_workflow/e2e-start-resource.sh'), ['ios'], { + encoding: 'utf8', + env: { + ...process.env, + HOME: home, + PATH: `${bin}:${process.env.PATH}`, + TMPDIR: path.join(root, 'tmp'), + TMUX_PANE: '%1', + IOS_BUILD_FAIL: '1', + }, + }); + assert.notEqual(result.status, 0); + assert.equal(result.stdout.trim(), ''); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); From 701580b56f0eeb0d87c22358cf628992c24990c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 31 Jul 2026 04:26:21 +0200 Subject: [PATCH 02/19] docs(workflow): round-scoped E2E slots and iOS-only default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the take→start→shards→serialized→stop→free lifecycle, platform scope table with iOS-only default, folded claim/build starts, and status device findings in WORKFLOW.md, the runbook, and README. --- .kilo_workflow/README.md | 6 ++-- .kilo_workflow/WORKFLOW.md | 26 +++++++++----- apps/mobile/e2e/AGENTS.md | 74 ++++++++++++++++++++++++++++---------- 3 files changed, 76 insertions(+), 30 deletions(-) diff --git a/.kilo_workflow/README.md b/.kilo_workflow/README.md index 3729bb61c65..4458d051416 100644 --- a/.kilo_workflow/README.md +++ b/.kilo_workflow/README.md @@ -53,11 +53,11 @@ A section can still take hours of unattended machine time when builds or live E2 | [`launch-gate.sh`](launch-gate.sh) | Spaces Kilo CLI startups through a recoverable machine-global lock | | [`await-interactive.sh`](await-interactive.sh) | Watches an interactive session for the monitor: COMPLETED, BLOCKED, DEAD, QUIET, or RUNNING | | [`steer.sh`](steer.sh) | Delivers a message to a running interactive session (starter, planner, orchestrator) and confirms it was submitted | -| [`e2e-take-slot.sh`](e2e-take-slot.sh) | Takes one of three machine-global E2E bundle slots (stack + iOS + Android + other resources) | -| [`e2e-start-resource.sh`](e2e-start-resource.sh) | Starts a stack, iOS simulator, Android emulator, or explicit custom resource after proving the caller holds a slot | +| [`e2e-take-slot.sh`](e2e-take-slot.sh) | Takes one of three machine-global E2E bundle slots for the current round (default: iOS-only scope) | +| [`e2e-start-resource.sh`](e2e-start-resource.sh) | Starts a stack, iOS simulator, Android emulator, or explicit custom resource after proving the caller holds a slot; also prebuilds/builds/claims the device as needed. Default bundle form is `--ios-only`; use `bundle ` for dual-platform | | [`e2e-stop-resource.sh`](e2e-stop-resource.sh) | Stops the matching resource through its repository wrapper | | [`e2e-free-slot.sh`](e2e-free-slot.sh) | Frees the caller's slot after its resources are stopped | -| [`e2e-slot-status.sh`](e2e-slot-status.sh) | Reclaims dead owners and reports live slots plus known resources with no slot | +| [`e2e-slot-status.sh`](e2e-slot-status.sh) | Reclaims dead owners and reports live slots plus known resources with no slot; also enumerates booted devices with no claim record and claimed devices that are no longer booted. The all-clear footer prints only when zero findings | | [`slice-diff.sh`](slice-diff.sh) | Emits one slice's diff for review plus a snapshot fingerprint; `--check` judges the round for violations | | [`baseline.sh`](baseline.sh) | Records a worktree's exact git state and later proves the E2E verifier's byte-identical restore | | [`pr-gate.sh`](pr-gate.sh) | Checks the completion gate's mechanical items against one head SHA (CI, threads, assignee, bot summary) | diff --git a/.kilo_workflow/WORKFLOW.md b/.kilo_workflow/WORKFLOW.md index 040c4880d89..c8ee77dd63e 100644 --- a/.kilo_workflow/WORKFLOW.md +++ b/.kilo_workflow/WORKFLOW.md @@ -122,7 +122,8 @@ Every dispatch to a role agent includes: - The mode; for hands-off, a direct instruction to never ask the user questions - Sanitized env values inline. Role agents must never read `.env`, `.env.*`, `.dev.vars`, or equivalent files; the handoff table is authoritative. Never place secrets or raw environment-file contents in a handoff. - The exact checks or flows expected for that stage -- For any device or stack phase: the slot-bundle owner, resources, and cleanup owner (see E2E Slots) +- For any device or stack phase: the slot-bundle owner, resources, cleanup owner, and the platform scope — exactly one of `none`, `ios`, `android`, `ios+android` — plus its rationale (`N/A — default` is enough for `none`/`ios`) and the exact start command from the E2E Loop scope table +- Continuation and re-verify handoffs restate the same platform scope, rationale, and start command (or `unchanged from round N`) - Prior findings being addressed, including rejected findings that must not reopen without new evidence - Priority order, minimum complete outcome, optional work to drop, and a clean stopping rule before budget exhaustion (estimate roughly one step per planned tool call; when in doubt, split the task); on early stop, the required continuation state (completed work, remaining work, failures, files touched, checks run or deferred, safest next action) - For long tool-heavy phases: output discipline — cap every shell command's output (`| tail -c 1500` / `| tail -5`), write dumps and captures to files and print only greps or counts, bound the final report. Oversized session payloads kill kilo runs silently mid-task. @@ -177,7 +178,7 @@ Steps: ### Bug Reproduction Gate -For defect work, the planner takes one slot and starts the whole required E2E bundle, then dispatches a fresh `e2e-verifier` in repro mode on the unmodified baseline as its FIRST act. The verifier runs while the planner explores and drafts. Dispatch with `--mode repro` (label `r0`; see Dispatching); the flag pins the sentinel contract, and the handoff names the planner as bundle owner. Its assignment: reproduce the reported issue — fix nothing — and return exact reproduction steps, evidence, and a failure classification. After the verdict, the planner stops every bundle resource and frees the slot. A confirmed reproduction feeds the plan, and the confirmed repro flow passing becomes an acceptance criterion the final verifier must rerun. +For defect work, the planner takes one slot and starts the whole required E2E bundle only when the repro dispatch is imminent, then dispatches a fresh `e2e-verifier` in repro mode on the unmodified baseline as its FIRST act. The default platform scope is iOS-only (`e2e-start-resource.sh bundle --ios-only`) unless the defect is platform-specific. The verifier runs while the planner explores and drafts. Dispatch with `--mode repro` (label `r0`; see Dispatching); the flag pins the sentinel contract, and the handoff names the planner as bundle owner. Its assignment: reproduce the reported issue — fix nothing — and return exact reproduction steps, evidence, and a failure classification. After the verdict, the planner stops every bundle resource and frees the slot. A confirmed reproduction feeds the plan, and the confirmed repro flow passing becomes an acceptance criterion the final verifier must rerun. A `CANNOT REPRODUCE.` sentinel is a blocker, not a license to fix an unconfirmed bug. Hands-on: return the evidence to the user and ask how to proceed. Hands-off: stop with a BLOCKED report — no plan, no orchestrator. `VERIFICATION BLOCKED.` means the environment prevented a verdict: make one reasonable environment recovery and dispatch one fresh repro verifier; a second blocked result makes the section BLOCKED. @@ -253,9 +254,18 @@ Request the top one or two names it prints: `gh pr edit --add-reviewer ### E2E Loop -1. The orchestrator takes one slot and starts the whole E2E bundle: one stack, up to one iOS simulator, up to one Android emulator, and any other resources this round needs — `e2e-start-resource.sh bundle ` brings up the stack and both device chains in parallel. Then dispatch a fresh `e2e-verifier` with the plan goals and acceptance criteria. When both iOS and Android are required, dispatch one verifier per platform in parallel; both share this one slot bundle and `apps/mobile/e2e/login.sh` gives them separate `-ios` and `-android` accounts. Each verifier has a hard 100-step ceiling: size its handoff below roughly 75 planned tool calls, and split a larger independent platform or flow scope into separate handoffs in the same bundle. Each handoff names the orchestrator as bundle owner and gives the exact assigned device and services. `dispatch-role.sh` gives every verifier a unique `$SCRATCH`, so parallel shards and later rounds cannot share baselines or temporary files. After all shards return — and after any serialized cross-platform or shared-fixture cases from the next step have run — the orchestrator stops every bundle resource and frees the slot. Serialize only a genuinely cross-platform flow or a test that needs shared mutable fixtures or temporary worktree edits. Never dispatch while implementers are active or uncommitted changes sit in an in-scope worktree — the verifier's byte-identical baseline restore turns concurrent edits into false failures. If a verifier round dies or is killed, the orchestrator still owns and cleans the bundle before redispatching. -2. Each verifier fails fast on the first confirmed product failure in its platform scope after capturing reproducible evidence; later cases in that scope are intentionally unrun. A sibling platform verifier already running continues its independent scope. Collect every in-flight shard's sentinel and wait for all of them to return before editing, committing, or dispatching an implementer. If every parallel shard passed, run any serialized cross-platform or shared-fixture cases now; otherwise leave them unrun for the follow-up. Then act on the union of sentinels: `VERIFICATION FAILED.` → triage the remarks (untrusted) and run the implementer → impl-reviewer loop to fix them, commit and push. `VERIFICATION BLOCKED.` → one environment recovery attempt and a fresh dispatch; if it blocks again, the section is BLOCKED. -3. Re-verify with the repaired failing case first. Then rerun every prior case in the same page, flow, component, or shared-dependency area and every case no completed round has proved. Carry forward a prior pass only when the handoff names the case, cites its round and evidence, and gives concrete changed-file or dependency evidence that the fix cannot affect it — for example, a page-2-only fix may carry forward page-1 cases with no shared dependency. The fresh verifier independently rejects weak carry-forward claims and runs those cases. +1. The orchestrator takes one slot and starts the whole E2E bundle per the platform scope recorded in the handoff: one stack, up to one iOS simulator, up to one Android emulator, and any other resources this round needs. The default platform scope is **iOS only** (`e2e-start-resource.sh bundle --ios-only`). Dual-platform requires a recorded rationale: the change touches platform-specific paths (native modules, platform-split components, Android/iOS-only code, layout/insets work) or a prior platform-specific defect exists. The supported scopes are: + + | Scope | When | Start command | + |---|---|---| + | none | stack-only/backend E2E, no devices | `e2e-start-resource.sh stack ...` | + | ios | default | `e2e-start-resource.sh bundle --ios-only` | + | android | Android-only defect or Android-only change | `e2e-start-resource.sh stack ...` + `e2e-start-resource.sh android ` | + | ios+android | platform-specific paths touched or prior platform defect | `e2e-start-resource.sh bundle ` | + + Then dispatch a fresh `e2e-verifier` with the plan goals and acceptance criteria. The verifier runs inside the assigned platform scope and stops with `VERIFICATION BLOCKED.` when the handoff's scope/devices disagree with what is running. Each verifier has a hard 100-step ceiling: size its handoff below roughly 75 planned tool calls, and split a larger independent platform or flow scope into separate handoffs in the same bundle. Each handoff names the orchestrator as bundle owner and gives the exact assigned scope, start command, and devices. `dispatch-role.sh` gives every verifier a unique `$SCRATCH` for baselines and temporary files only; reusable flow scripts and assertions live in the section's scratch artifacts directory. After all parallel shards return, any serialized cross-platform or shared-fixture cases run now while the slot is still held (in-round verification, not repair). Then the orchestrator stops every bundle resource and frees the slot. Serialize only a genuinely cross-platform flow or a test that needs shared mutable fixtures or temporary worktree edits. Never dispatch while implementers are active or uncommitted changes sit in an in-scope worktree — the verifier's byte-identical baseline restore turns concurrent edits into false failures. If a verifier round dies or is killed, the orchestrator still owns and cleans the bundle before redispatching. +2. Each verifier fails fast on the first confirmed product failure in its platform scope after capturing reproducible evidence; later cases in that scope are intentionally unrun. A sibling platform verifier already running continues its independent scope. Collect every in-flight shard's sentinel and wait for all of them to return before editing, committing, or dispatching an implementer. Then act on the union of sentinels: `VERIFICATION FAILED.` → triage the remarks (untrusted) and run the implementer → impl-reviewer loop to fix them, commit and push. `VERIFICATION BLOCKED.` → one environment recovery attempt and a fresh dispatch; if it blocks again, the section is BLOCKED. A `VERIFICATION BLOCKED.` environment recovery is a device phase: re-take the slot, start the same platform scope, recover or redispatch the verifier, stop every bundle resource, and free the slot. +3. Re-verify with the repaired failing case first. Then rerun every prior case in the same page, flow, component, or shared-dependency area and every case no completed round has proved. Carry forward a prior pass only when the handoff names the case, cites its round and evidence, and gives concrete changed-file or dependency evidence that the fix cannot affect it — for example, a page-2-only fix may carry forward page-1 cases with no shared dependency. The fresh verifier independently rejects weak carry-forward claims and runs those cases. A re-verification round re-takes the slot and repeats the pinned lifecycle: take → start the same platform scope → dispatch the verifier → wait → stop every bundle resource → free the slot. 4. Exit when every platform shard returns `VERIFICATION PASSED.` and the current rounds plus explicitly justified carried-forward proof cover every criterion. After five rounds without that, the remaining moves are takeover or BLOCKED (see Escalation). ### Kilobot Loop @@ -300,7 +310,7 @@ Any step where an agent or LLM must actually respond — cloud-agent sessions, c ## E2E Slots -The machine is shared by parallel workflows, and unslotted E2E work overloads it. Only E2E bundles are capped — agents themselves are never capped. One slot permits one round's complete bundle: one dev stack, up to one iOS simulator, up to one Android emulator, and its remote CLIs, browsers, native builds, and other resources. A slot is not per resource or per verifier; parallel platform verifiers in one round share the same bundle. +The machine is shared by parallel workflows, and unslotted E2E work overloads it. Only E2E bundles are capped — agents themselves are never capped. One slot permits one round's complete bundle: one dev stack, up to one iOS simulator, up to one Android emulator, and its remote CLIs, browsers, native builds, and other resources. A slot is not per resource or per verifier; parallel platform verifiers in one round share the same bundle. Slot ownership is round-scoped: the bundle owner takes the slot at the start of the device/stack phase, holds it through the round's E2E work and any in-round verification, and frees it immediately after stopping every bundle resource. The planner owns the repro bundle. The orchestrator owns implementation-verification bundles. That owner follows this lifecycle plus status check (default 3 slots, machine-global): @@ -315,11 +325,11 @@ The planner owns the repro bundle. The orchestrator owns implementation-verifica The take/free scripts resolve the caller's own tmux session themselves and refuse to run outside one. -`e2e-take-slot.sh` atomically takes one of three tokens and `e2e-free-slot.sh` returns it. Start and stop resources through the paired scripts; `stack`, `ios`, and `android` dispatch to the repository wrappers, while `command` runs another explicit start/stop command. Stop every resource first, then free the slot. `e2e-slot-status.sh` reports live holders and known stacks/device records with no slot owner; it never kills them. +`e2e-take-slot.sh` atomically takes one of three tokens and `e2e-free-slot.sh` returns it. Start and stop resources through the paired scripts; `stack`, `ios`, and `android` dispatch to the repository wrappers, while `command` runs another explicit start/stop command. Stop every resource first, then free the slot. `e2e-slot-status.sh` reports live holders, known stacks/device records with no slot owner, booted devices with no claim record, and claimed devices that are no longer booted; it never kills them. Every device/stack phase — main loop, `VERIFICATION BLOCKED.` recovery, and Kilobot-driven re-verify — uses the same round lifecycle: take → start → stop → free. - Slot state lives in `$HOME/.cache/kilo-e2e-slots`, machine-global by design: every copy of the script — any worktree, any repository — contends for the same slots, and the script has no overrides by design. When working in a repository without the script (a sibling like `~/Projects/kilocode`), invoke it by absolute path from a cloud worktree. - This holds on every run, not only when another workflow is visibly active, and a stack that is already up is not an exemption. -- A blocked `e2e-take-slot.sh` is correct behavior, never a wedge to route around and never a reason to start device work unslotted. If it is still blocked after about 45 minutes, inspect `e2e-slot-status.sh` for a wedged foreign holder and report a blocker instead of waiting forever. +- A blocked `e2e-take-slot.sh` is correct behavior, never a wedge to route around and never a reason to start device work unslotted. Each call blocks up to ~8 minutes while reporting holders; re-invoke while waiting, and after ~45 minutes total inspect `e2e-slot-status.sh` for a wedged foreign holder and report a blocker instead of waiting forever. - The slot caps load, not data: postgres and redis containers are shared across worktrees. Keep test data keyed to this worktree's accounts (the runbooks' per-worktree defaults) and never wipe shared state. - Release immediately after cleaning up the resources used by the device/stack phase. Planning, implementation, review, checks, and CI waits are uncapped; never hold a slot through them. - Slots are owned by tmux session name and reclaimed automatically when the session dies. A holder that is alive but wedged belongs to its own workflow's monitor — never kill another session to free a slot; if the queue is starved by a foreign wedge, report a blocker to the user instead. diff --git a/apps/mobile/e2e/AGENTS.md b/apps/mobile/e2e/AGENTS.md index a3f1ab61559..823ff7b1815 100644 --- a/apps/mobile/e2e/AGENTS.md +++ b/apps/mobile/e2e/AGENTS.md @@ -14,10 +14,20 @@ Slots". Before dispatching verifiers, the owner runs: ``` The owner starts every bundle resource through `e2e-start-resource.sh`, passes -the ready services and device identifiers in each verifier handoff, then stops -every resource through `e2e-stop-resource.sh` before freeing the slot. Taking -is idempotent per session. Parallel iOS and Android verifiers share this bundle -and never take another slot. +its platform scope, start command, and the ready services and device identifiers +in each verifier handoff, then stops every resource it started through +`e2e-stop-resource.sh` before freeing the slot. Taking is idempotent per session. +One iOS verifier is the default for this bundle; parallel iOS and Android shards +run only when the handoff records a platform rationale (platform-specific paths +touched or a prior platform-specific defect). Parallel platform shards share the +same slot bundle and never take another slot. The slot owner is responsible for +the whole round lifecycle; do not hold a slot through planning, review, or CI +waits. + +Slot ownership is round-scoped and the platform scope is recorded in every +device/stack handoff. The default is iOS-only; dual-platform requires a +recorded rationale. See `.kilo_workflow/WORKFLOW.md` "E2E Slots" and "E2E Loop" +for the scope table and lifecycle. ## Fresh Worktree Quickstart @@ -51,12 +61,24 @@ after taking its slot: The script also refreshes the session-ingest Secrets Store binding, applies migrations, and fails fast when a requested service or the kiloclaw docker bridge never comes up. -To bring the whole bundle up at once (stack, iOS claim+build, Android start+claim+build — three chains in parallel, the slowest sets the wall time instead of the sum): +To bring the whole bundle up at once (stack, iOS claim+build, and optionally +Android start+claim+build — selected chains run in parallel, the slowest sets +the wall time instead of the sum): + +```bash +.kilo_workflow/e2e-start-resource.sh bundle --ios-only +``` + +For dual-platform rounds (recorded rationale required): ```bash .kilo_workflow/e2e-start-resource.sh bundle [--gpu host] ``` +The start wrapper prebuilds the generated `apps/mobile/{ios,android}` projects +when needed and emits compact JSON / summary lines on stdout for the devices it +claims. The generated trees are gitignored. + Rules: - Do not export `KILO_PORT_OFFSET` or source `apps/mobile/.env`; stale shell values select the wrong bundle endpoints. Secondary worktrees get an isolated port offset automatically, and startup injects this worktree's LAN URLs before Metro starts. When the automatic offset lands on occupied ports (AirPlay on 5000/7000, another worktree's stack), `dev:start` probes for a free offset itself and persists it before services launch; later `dev:restart`/`dev:env` reuse the persisted value, so no manual prefix is needed. A per-command `KILO_PORT_OFFSET=` prefix still overrides everything when you must pin one — never `export` it. @@ -107,21 +129,25 @@ Pinned surface only: REST pull/repo/check-runs/statuses/`pulls/{n}/files` (pagin ## iOS Simulator -The bundle owner claims and builds before dispatch. Never share a simulator -with another worktree; the claim command prefers an unclaimed shutdown iPhone -and boots it: +The bundle owner claims and builds before dispatch. The start wrapper does both +in one command; never share a simulator with another worktree. The claim command +prefers an unclaimed shutdown iPhone and boots it: ```bash -.kilo_workflow/e2e-start-resource.sh ios [udid] # idempotent per worktree +.kilo_workflow/e2e-start-resource.sh ios [udid] # claim + build, idempotent per worktree .kilo_workflow/e2e-stop-resource.sh ios # this worktree's claims ``` The wrapper renames the claimed device to `Kilo E2E - ` and restores the original name on release. Never call `xcrun simctl rename` yourself. The claim also records whether it was the thing that booted the device, which is what lets release power off only the devices it started — so never boot or shut down an E2E simulator with `xcrun simctl` behind the wrapper's back. A claim is stale — and silently reclaimable — once its owning worktree is deleted. A claim killed mid-boot boots the Shutdown device again on reclaim automatically; give claims a >=10-minute timeout under parallel-workflow load (booting is slow). -Install a validated cached native build. A compatible fingerprint skips rebuilding; a cache miss serializes through the host-wide native compiler semaphore. Never install an arbitrary DerivedData app or run a separate Expo native build: +Install a validated cached native build. `e2e-start-resource.sh ios` already +runs this for the claimed device; use the standalone build command only for +rebuilds. A compatible fingerprint skips rebuilding; a cache miss serializes +through the host-wide native compiler semaphore. Never install an arbitrary +DerivedData app or run a separate Expo native build: ```bash -pnpm dev:mobile:ios build +pnpm dev:mobile:ios build # rebuild only; the start wrapper already builds ``` Connect the app to the Metro URL shown by this worktree's `mobile` pane: @@ -278,17 +304,26 @@ On failure run `.kilo_workflow/e2e-stop-resource.sh android`; it uses the record ### Build and login -Claim the serial (idempotent), then build: +`e2e-start-resource.sh android ` already prebuilds (when +`apps/mobile/android/` is missing), starts the emulator, claims the serial, and +builds it. On the primary path, run login next: ```bash -pnpm dev:mobile:android claim -pnpm dev:mobile:android build # validated cached APK only -apps/mobile/e2e/login.sh +EMULATOR=$(.kilo_workflow/e2e-start-resource.sh android --gpu host) +SERIAL=$(jq -r .serial <<<"$EMULATOR") +apps/mobile/e2e/login.sh "$SERIAL" ``` -`build` installs a validated cached APK when the Android native fingerprint and toolchain match. Never install an APK from another output path or invoke Gradle directly. Reinstall via `build ` only when the native fingerprint changed, never to reset app state. +`build` installs a validated cached APK when the Android native fingerprint and toolchain match. Never install an APK from another output path or invoke Gradle directly. Reinstall via `build ` only when the native fingerprint changed, never to reset app state. For rebuilds only, the manual claim and build commands are: + +```bash +pnpm dev:mobile:android claim # idempotent +pnpm dev:mobile:android build # validated cached APK only +``` -`build` reads the generated Android project, and `apps/mobile/android/` is git-ignored — a fresh worktree has none. Run `npx expo prebuild --platform android` in `apps/mobile` once before the first `build`; it is codegen only, needs no wrapper, and a missing project is the one failure `build` cannot fix itself. +`apps/mobile/android/` is git-ignored and a fresh worktree has none. The +wrapper runs `npx expo prebuild --platform android` when needed; that step is +codegen only, needs no wrapper, and the generated tree is gitignored. `login.sh` and `logout.sh` accept an iOS simulator UDID or an Android ADB serial. On Android, `login.sh`'s shared preflight verifies the claim, applies both `adb reverse` mappings (the `nextjs` service's API port and the `mobile` service's Metro port, both from `pnpm dev:status --json` — there is no service named `metro`), and opens the dev-client deep link itself. On the primary path no manual reverse or `am start` is needed. @@ -338,12 +373,13 @@ rm -f "$LOGIN_LOG" After all verifiers return, the bundle owner stops everything it started — conditional resources first (skip any never started), then the bundle — and -frees the slot: +frees the slot. An iOS-only round has no Android resources to stop, so the +Android stop line is skipped by the same skip-any-never-started rule: ```bash apps/mobile/e2e/remote-cli.sh stop # only after remote-cli.sh start tmux kill-session -t "kilo-e2e-github-stub-$(basename "$PWD")" 2>/dev/null || true # only after the GitHub stub -.kilo_workflow/e2e-stop-resource.sh android +.kilo_workflow/e2e-stop-resource.sh android # only if android was started .kilo_workflow/e2e-stop-resource.sh ios .kilo_workflow/e2e-stop-resource.sh stack .kilo_workflow/e2e-free-slot.sh # refuses while any resource above is still live From 640fc1450b931a2efa7c5c219f2b0761d584ac94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Fri, 31 Jul 2026 05:23:18 +0200 Subject: [PATCH 03/19] feat(workflow): video-first E2E evidence, flow batching, artifact reuse Add record.sh for segment-scoped device video, teach multi-flow Appium batching, inject section-level E2E_ARTIFACTS for verifier reuse, and document the contracts in the runbook and e2e-verifier role. --- .kilo/agent/e2e-verifier.md | 11 +- .kilo_workflow/WORKFLOW.md | 5 +- .kilo_workflow/dispatch-role.sh | 9 +- apps/mobile/e2e/AGENTS.md | 24 ++- apps/mobile/e2e/appium.sh | 2 +- apps/mobile/e2e/record.sh | 221 +++++++++++++++++++++ dev/local/appium-wrapper.test.ts | 298 +++++++++++++++++++++++++++++ dev/local/workflow-scripts.test.ts | 5 + 8 files changed, 561 insertions(+), 14 deletions(-) create mode 100755 apps/mobile/e2e/record.sh diff --git a/.kilo/agent/e2e-verifier.md b/.kilo/agent/e2e-verifier.md index 32143ba09b6..8e7fdac0c0d 100644 --- a/.kilo/agent/e2e-verifier.md +++ b/.kilo/agent/e2e-verifier.md @@ -21,10 +21,11 @@ The handoff defines your priority order, minimum complete outcome, optional work Before testing: 1. Read the learnings — the worktree's `.kilo_workflow/learnings/` plus the main checkout's `~/Projects/cloud/.kilo_workflow/learnings/system/` — then the surface-specific runbook, and follow it exactly for services, device claiming, builds, login, automation drivers, prompts, and cleanup — mobile: `apps/mobile/e2e/AGENTS.md`; extension: `apps/extension/AGENTS.md`; web and services: `DEVELOPMENT.md` and the repository dev runner. Never bypass a helper script's preflight, install unvalidated builds, or guess selectors. -2. Translate the plan's goals and acceptance criteria into observable flows; for user-facing features, cover the happy, retryable-unhappy, non-retryable-unhappy, and empty states. Batch aggressively: one booted device, one running stack, and one logged-in app session can verify MANY criteria — plan a route through the app that covers every reachable criterion in sequence and only relaunch, re-login, or reboot when a criterion genuinely requires fresh state. Separate runs per scenario burn device time and slot hours for nothing. -3. Confirm the handoff names the live slot-bundle owner, worktree services, and your assigned devices. The planner owns a repro bundle; the orchestrator owns a verification bundle. Never take another slot or start, stop, or replace its resources. Stop immediately with `VERIFICATION BLOCKED.` if the handoff omits ownership or an assigned resource is not ready. +2. Translate the plan's goals and acceptance criteria into observable flows; for user-facing features, cover the happy, retryable-unhappy, non-retryable-unhappy, and empty states. Batch aggressively: one booted device, one running stack, and one logged-in app session can verify MANY criteria — plan a route through the app that covers every reachable criterion in sequence and only relaunch, re-login, or reboot when a criterion genuinely requires fresh state. Separate runs per scenario burn device time and slot hours for nothing. For Appium, batch verifier-written flows into as few `apps/mobile/e2e/appium.sh test … [more-flows.js]` invocations as possible (one WebDriver session per invocation; session startup dominates cost). `login.sh` / `logout.sh` keep their own per-call sessions. +3. Confirm the handoff names the live slot-bundle owner, worktree services, your assigned devices, and the platform scope (`ios` default; `android` or dual only with the recorded rationale). Verify only within that scope. Stop immediately with `VERIFICATION BLOCKED.` if the handoff omits ownership, an assigned resource is not ready, or the handoff's scope/devices disagree with what is running. The planner owns a repro bundle; the orchestrator owns a verification bundle. Never take another slot or start, stop, or replace its resources. 4. Record pre-existing services, listeners, devices, and tmux sessions. Never stop another worktree's stack or use a device claimed by another worktree. 5. Before any temporary edit, set `WT` to each in-scope worktree and record its own baseline: `.kilo_workflow/baseline.sh snapshot "$WT" "$SCRATCH/baseline-$(basename "$WT")" --include .env.local --include apps/mobile/.env.local` (always outside every repository). Add another repeatable `--include ` for any other ignored file the handoff permits. Use the identical directory and include flags for the final `check`. Separately copy the original bytes and mode of every tracked file you plan to edit — the snapshot proves divergence; your copies are what restore it. Temporary edits may touch only paths that are clean and tracked at baseline, or brand-new paths — never a pre-existing modified, staged, or untracked path. +6. When `$E2E_ARTIFACTS` is set (section scratch `e2e-artifacts/`), evaluate any prior reusable flow scripts/assertions named in the handoff (`-.js`) before rewriting them. Prior artifacts are evidence, not truth — reject faulty ones and re-derive. Save new reusable flows there under the handoff-assigned names. Baselines and temp files stay in the unique per-round `$SCRATCH`. Output discipline — long verification runs die when their session payload grows too large. Cap every shell command's output (`| tail -c 1500` or `| tail -5`); write hierarchies, captures, and service logs to files and print only greps or counts; never re-read screenshots into context; keep your final report bounded. @@ -35,7 +36,8 @@ During verification: - In a follow-up round, run the repaired failing case first, then every previously passed case in the same page, flow, component, or shared-dependency area, plus every case no completed round has proved. A handoff may carry forward a previously passed case only when it names that case and gives concrete changed-file or dependency evidence that the fix cannot affect it; independently reject a weak rationale and run the case. Carried-forward proof is not a skip. - Retryable and empty states: a meaningful message plus a CTA that performs the expected recovery or next step. Non-retryable states: a meaningful message with no CTA at all. - Inspect backend or service logs when a flow crosses those boundaries. -- Capture concise evidence: screenshots, exact visible state, and bounded log excerpts. Never credentials. +- **Video-first evidence:** record each flow segment (or criterion group) with `apps/mobile/e2e/record.sh start ` / `stop`; extract report frames with `record.sh frame