verikun 0.24.0 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -141,7 +141,10 @@ banner, the retry path, dark theme, a layout that breaks at accessibility text s
141
141
  you reset it, so don't leave someone's phone in airplane mode.
142
142
 
143
143
  - `vk device prep [--dry-run] [--revert]` — set a **test** device up once, stickily:
144
- `animations=off stay-awake=on screen-timeout=max dnd=on doze=off`.
144
+ `animations=off stay-awake=off screen-timeout=1m dnd=on doze=off`. The display then sleeps by
145
+ itself a minute after the last command and is woken by the next one; `--no-sleep-when-idle`
146
+ keeps it lit for good (`stay-awake=on screen-timeout=max`), which is what a device with a
147
+ PIN/pattern lock needs, since verikun can only clear a *swipe* lock.
145
148
 
146
149
  Unlike `device set`, prep **survives the run** and is undone only by `--revert`. A physical
147
150
  device must be named (`--device <serial>`) — that requirement is deliberate, so prep can
@@ -452,6 +455,19 @@ device; `503` means the server has no device attached — boot one (below). To
452
455
  expose a device from THIS machine: `vk server --allow-install`
453
456
  (add `--bind <addr>` to leave loopback; auth key auto-generates if unset).
454
457
 
458
+ **If you see `[verikun] server moved device: A → B` on stderr**, the server left a
459
+ device that failed and is now on another one. What that means depends on the line:
460
+
461
+ - `— retried there` (installs only): the build DID land, on **B**. Anything you go on
462
+ to do with an explicit serial must name B, not A.
463
+ - `— this step failed on the old device; the next runs on the new one`: your step
464
+ failed on **A**. Do NOT re-run it expecting a different answer for the same reason —
465
+ the failure was real on A, and B has none of the state your flow built up. Start the
466
+ flow again from the top if you want it on B.
467
+
468
+ The server rules the bad device out until it is power-cycled;
469
+ `vk devices --server <url>` shows why in its `NOTE` column.
470
+
455
471
  ## The device is missing or wedged
456
472
 
457
473
  ```sh
@@ -536,6 +552,8 @@ owns the redaction and the review-first flow.
536
552
  verikun detects this, wakes the device and clears a *swipe* lock automatically; on a
537
553
  PIN/pattern/password it exits **3** naming the lock rather than returning that dump.
538
554
  Tell the user to remove the lock in Settings > Security — verikun never asks for a PIN.
555
+ Taps, swipes, typing and screenshots wake it the same way: a prepped display sleeps after a
556
+ minute idle, and an injected tap on a sleeping screen would otherwise do nothing and exit `0`.
539
557
  - **Ambiguous selector → exit 2**, never a random tap. `vk` prints the candidate
540
558
  matches; add `--index N` or use a more specific selector.
541
559
  - **Indexes are per-snapshot.** `vk tap 3` taps `[3]` from the *latest* dump;
package/CHANGELOG.md CHANGED
@@ -6,6 +6,37 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.25.1] - 2026-08-21
10
+
11
+ ### Changed
12
+ - **`vk device prep`**: gives the display a 1-minute timeout instead of keeping it lit forever; `--no-sleep-when-idle` keeps the old behaviour. ([#101])
13
+ - **`vk batch|ai|suite`**: no longer switch a prepped device's display off at teardown — it now sleeps by itself.
14
+ - **`vk tap|type|key|swipe|screenshot`**: wake a sleeping display first; an injected tap on a dozing screen did nothing and exited `0`.
15
+
16
+ ### Added
17
+ - **`vk device prep`**: warns when a device that will now sleep is behind a PIN/pattern lock.
18
+
19
+ [#101]: https://github.com/ddikman/verikun/issues/101
20
+
21
+ ## [0.25.0] - 2026-08-21
22
+
23
+ ### Added
24
+ - **`vk server`**: moves to another attached healthy device when the bound one fails; only a pinned `--device` disables it. ([#99])
25
+ - **`vk server --allow-failover[=serials]`**: re-enable failover on a pinned server, and bound where it may go.
26
+ - **`vk server --no-failover`** / **`VERIKUN_NO_FAILOVER`**: disable failover outright.
27
+ - **`/v1/health`**: adds `failoverEnabled` and `quarantined`, so a client can see which devices the server ruled out.
28
+ - **`vk install --server`**: reports the device the build landed on when it differs from the one bound.
29
+
30
+ ### Changed
31
+ - **`vk install --server`**: a device-attributable install failure retries on another healthy device.
32
+ - **`/v1/exec`**, **`/v1/elements`**: report `deviceChanged` when the server moved device; the failing step is never replayed.
33
+
34
+ ### Fixed
35
+ - **`/v1/logs`**: served the startup device's logs after a rebind instead of the bound device's.
36
+ - **`/v1/health`**: reported the startup device's read path after a rebind.
37
+
38
+ [#99]: https://github.com/ddikman/verikun/issues/99
39
+
9
40
  ## [0.24.0] - 2026-08-20
10
41
 
11
42
  ### Added
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
  - **Puppeteer for native mobile** — a thin wrapper over native Android and iOS automation runners with zero runtime dependencies.
9
9
  - **Natural-language tests** — `vk ai <file>`: runs plain-English tests, compiled once and replayed model-free (~$0), calling a model only to self-heal a drifted step. [What that costs](https://ddikman.github.io/verikun/reference/cost/), and how the `--max-cost-usd` ceiling bounds it.
10
10
  - **Self-improving** — the agent runner will provide prescriptive improvements to existing scripts to help stabilise flakiness for future runs.
11
- - **CI-ready** — `vk suite` runs a folder of tests as one gated pass/fail run; `vk server` exposes a real device over an authenticated tunnel so a disposable CI runner (no phone attached) can still drive it.
11
+ - **CI-ready** — `vk suite` runs a folder of tests as one gated pass/fail run; `vk server` exposes a real device over an authenticated tunnel so a disposable CI runner (no phone attached) can still drive it, and moves to another attached device if that one goes bad.
12
12
 
13
13
  ```
14
14
  $ vk ui
@@ -38,7 +38,7 @@ The package also carries the agent [`SKILL.md`](.claude/skills/verikun/SKILL.md)
38
38
 
39
39
  ```sh
40
40
  vk doctor # check adb/device (read-only — never changes anything)
41
- vk device prep --device <id> # set a TEST device up once: animations off, stays awake
41
+ vk device prep --device <id> # set a TEST device up once: animations off, sane display timeout
42
42
  vk devices # list attached devices
43
43
  vk ui # semantic snapshot of the current screen
44
44
  vk tap @login_button # tap by resource-id
@@ -89,8 +89,14 @@ class RemoteTransport {
89
89
  finally {
90
90
  clearTimeout(timer);
91
91
  }
92
- if (!res.ok)
93
- throw describeStatus(res.status, await readBody(res), url);
92
+ if (!res.ok) {
93
+ const body = await readBody(res);
94
+ // Before throwing: a failing request may still have moved the device, and that is
95
+ // exactly the case a caller must not miss (an exhausted install, a dead-device read).
96
+ if (body?.deviceChanged)
97
+ this.opts.onDeviceChange?.(body.deviceChanged);
98
+ throw describeStatus(res.status, body, url);
99
+ }
94
100
  const parsed = await readBody(res);
95
101
  if (parsed === null)
96
102
  throw new errors_1.CliError(`verikun server at ${url} returned a non-JSON response`, 3);
@@ -157,6 +163,9 @@ function createRemoteBackend(opts, health) {
157
163
  const res = await t.postJson('/v1/exec', req, EXEC_TIMEOUT_MS);
158
164
  if (record && res.step)
159
165
  opts.onStep?.(res.step, decodeArtifacts(res.artifacts), res.logStart);
166
+ // A failing step is a 200, so this is the ordinary path for a mid-run device death.
167
+ if (res.deviceChanged)
168
+ opts.onDeviceChange?.(res.deviceChanged);
160
169
  return { code: res.code, error: res.error ? (0, rpc_1.rebuildError)(res.error) : undefined };
161
170
  };
162
171
  return {
@@ -184,11 +193,15 @@ function createRemoteBackend(opts, health) {
184
193
  throw new errors_1.CliError(`install: cannot read '${appPath}' (${e.message})`, 2);
185
194
  }
186
195
  const sha256 = (0, node_crypto_1.createHash)('sha256').update(buf).digest('hex');
187
- await t.request('POST', '/v1/install', buf, INSTALL_TIMEOUT_MS, {
196
+ const res = await t.request('POST', '/v1/install', buf, INSTALL_TIMEOUT_MS, {
188
197
  'content-type': 'application/octet-stream',
189
198
  'x-verikun-ext': ext,
190
199
  'x-verikun-sha256': sha256,
191
200
  });
201
+ // Install is the one operation the server replays elsewhere, so a move here means
202
+ // the build DID land — on a different device than the one we started with.
203
+ if (res.deviceChanged)
204
+ opts.onDeviceChange?.(res.deviceChanged);
192
205
  },
193
206
  async reset(appId) {
194
207
  // Between-test housekeeping (vk suite): the step is deliberately NOT spliced
package/dist/args.js CHANGED
@@ -49,10 +49,12 @@ const BOOLEAN = new Set([
49
49
  'no-sleep-when-idle',
50
50
  'allow-install',
51
51
  'allow-unsafe-anonymous',
52
- // NOT here on purpose: 'allow-device-control' and 'ensure-device' are tri-state
53
- // (absent / on / on-with-a-value), and the inline `--flag=value` branch is checked
54
- // BEFORE this set listing them would make `--allow-device-control=Pixel_6` parse
55
- // fine but `--ensure-device Pixel_6` silently become a boolean plus a positional.
52
+ 'no-failover',
53
+ // NOT here on purpose: 'allow-device-control', 'allow-failover' and 'ensure-device'
54
+ // are tri-state (absent / on / on-with-a-value), and the inline `--flag=value` branch
55
+ // is checked BEFORE this set listing them would make `--allow-device-control=Pixel_6`
56
+ // parse fine but `--ensure-device Pixel_6` silently become a boolean plus a positional.
57
+ // Its opposite, 'no-failover', never takes a value, so it DOES belong above.
56
58
  // Selector state modifiers (STATE_ATTRS in ui/selector.ts) and their negations.
57
59
  // `enabled` was missing here until 0.15.0, and the omission was not cosmetic: a
58
60
  // non-BOOLEAN flag swallows the next token, so `vk tap --enabled @submit` bound
package/dist/cli.js CHANGED
@@ -50,6 +50,7 @@ exports.chooseLogOpts = chooseLogOpts;
50
50
  exports.evalAssert = evalAssert;
51
51
  exports.tokenizeLine = tokenizeLine;
52
52
  exports.withBatchGlobals = withBatchGlobals;
53
+ exports.retryAfterDeviceMove = retryAfterDeviceMove;
53
54
  exports.serverFromFlags = serverFromFlags;
54
55
  exports.ensureDeviceTarget = ensureDeviceTarget;
55
56
  exports.terminalFailure = terminalFailure;
@@ -1393,10 +1394,12 @@ function devicePrep(ctx) {
1393
1394
  const serial = ctx.driver.resolvedSerial();
1394
1395
  if ((0, args_1.flagBool)(ctx.flags, 'revert'))
1395
1396
  return devicePrepRevert(ctx, serial, dryRun, asJson);
1397
+ // Which display policy: sleeps by itself after PREP_SCREEN_TIMEOUT, or never turns off.
1398
+ const sleepWhenIdle = !(0, args_1.flagBool)(ctx.flags, 'no-sleep-when-idle');
1396
1399
  // Partition by what this platform can actually do, so the gate below counts real writes.
1397
1400
  const skipped = [];
1398
1401
  const applicable = [];
1399
- for (const knob of prep_1.PREP_KNOBS) {
1402
+ for (const knob of (0, prep_1.prepKnobs)(sleepWhenIdle)) {
1400
1403
  const spec = settings_1.SETTINGS[knob.key];
1401
1404
  const support = spec.support[ctx.platform];
1402
1405
  if (support === 'unsupported') {
@@ -1450,7 +1453,6 @@ function devicePrep(ctx) {
1450
1453
  }
1451
1454
  // Earliest wins across re-preps, or `--revert` would restore the device to prepped.
1452
1455
  const prior = (0, prep_1.readPrep)(serial);
1453
- const sleepWhenIdle = !(0, args_1.flagBool)(ctx.flags, 'no-sleep-when-idle');
1454
1456
  (0, prep_1.writePrep)((0, prep_1.newPrepRecord)(serial, ctx.platform, (0, prep_1.mergeOriginals)(prior?.original ?? {}, original), sleepWhenIdle));
1455
1457
  const applied = Object.fromEntries(applicable.map((k) => [k.key, k.target]));
1456
1458
  ctx.record?.note({ message: `device prep ${serial} (${changes.length} changed)` });
@@ -1462,11 +1464,32 @@ function devicePrep(ctx) {
1462
1464
  for (const s of skipped)
1463
1465
  (0, output_1.err)(`note: ${s.key} skipped — ${s.reason.replace(/\n/g, ' ')}`);
1464
1466
  if (sleepWhenIdle)
1465
- (0, output_1.err)('note: this device will be put to sleep when a run using it finishes');
1467
+ (0, output_1.err)(`note: the display sleeps by itself after ${prep_1.PREP_SCREEN_TIMEOUT} idle, and is woken on the next read`);
1468
+ warnSecureLock(ctx.platform, serial, sleepWhenIdle);
1466
1469
  (0, output_1.err)(`undo with: verikun device prep --revert --device ${serial}`);
1467
1470
  }
1468
1471
  return 0;
1469
1472
  }
1473
+ /**
1474
+ * Say so, once, when a device that will now sleep is behind a SECURE lock.
1475
+ *
1476
+ * The wake on the read path can only clear a *swipe* keyguard; a PIN/pattern/password one makes
1477
+ * `getElements` exit 3 rather than hand back the lock screen. That is honest, but it is a
1478
+ * failure, and prep — an explicit setup command that already resolved the serial — is where you
1479
+ * want to hear about it, not twenty minutes into a suite. It only warns: refusing would make a
1480
+ * perfectly usable device un-preppable, and `--no-sleep-when-idle` is the way out.
1481
+ */
1482
+ function warnSecureLock(platform, serial, sleepWhenIdle) {
1483
+ if (!sleepWhenIdle || platform !== 'android')
1484
+ return;
1485
+ const lock = (0, adb_1.lockKindOf)(serial);
1486
+ if (lock === 'none' || lock === 'unknown')
1487
+ return;
1488
+ (0, output_1.err)(`warning: this device has a screen lock (${lock}), so a read after the display sleeps can land ` +
1489
+ 'on the keyguard (verikun can only clear a swipe lock, and never asks for a PIN).\n' +
1490
+ 'Remove it in Settings > Security, or keep the display lit with `verikun device prep ' +
1491
+ `--no-sleep-when-idle --device ${serial}\`.`);
1492
+ }
1470
1493
  /** Put a prepared device back the way it was found, and forget it. */
1471
1494
  function devicePrepRevert(ctx, serial, dryRun, asJson) {
1472
1495
  const rec = (0, prep_1.readPrep)(serial);
@@ -1556,35 +1579,6 @@ function requireSettingKey(v) {
1556
1579
  * same code. (Remote is a known gap: the overrides live in the *server's* run file, so
1557
1580
  * a locally-empty snapshot means this correctly skips — see the issue's Out of scope.)
1558
1581
  */
1559
- /**
1560
- * Park prepared devices when the flow that used them ends — #97's "in sleep mode when they're
1561
- * not [in use]".
1562
- *
1563
- * Three properties worth keeping:
1564
- *
1565
- * * It can only ever touch a device you explicitly PREPPED, and only if that prep did not
1566
- * pass `--no-sleep-when-idle`. A borrowed phone that was never prepped is never slept.
1567
- * * It goes through the driver rather than the command dispatcher, so it does not appear as
1568
- * a `key sleep` testcase in the report. Parking is host hygiene, not a test step.
1569
- * * `ownClaimedSerials()` is empty in `--server` mode (the local process never resolves a
1570
- * device), so the remote path degrades to doing nothing on its own, with no branch here.
1571
- * Managing a remote device's power is `--allow-device-control`'s job.
1572
- *
1573
- * Must run BEFORE `releaseOwnClaims()`, which is what it reads its serials from.
1574
- */
1575
- function parkPreparedDevices(platform) {
1576
- for (const serial of (0, claims_1.ownClaimedSerials)()) {
1577
- if (!(0, prep_1.readPrep)(serial)?.sleepWhenIdle)
1578
- continue;
1579
- try {
1580
- (0, output_1.err)(`[verikun] parking prepared device ${serial} (sleep)`);
1581
- (0, drivers_1.getDriver)(platform, serial).pressKey('sleep');
1582
- }
1583
- catch {
1584
- /* teardown must never throw — the device may be exactly why we are unwinding */
1585
- }
1586
- }
1587
- }
1588
1582
  async function restoreDeviceOverrides(backend) {
1589
1583
  if (!run_1.Recorder.hasDeviceOverrides())
1590
1584
  return;
@@ -1865,7 +1859,6 @@ async function cmdBatch(positionals, batchFlags) {
1865
1859
  /* the device may be exactly why we are unwinding — never mask the real error */
1866
1860
  }
1867
1861
  }
1868
- parkPreparedDevices(platformFromFlags(batchFlags));
1869
1862
  (0, claims_1.releaseOwnClaims)();
1870
1863
  }
1871
1864
  }
@@ -1999,6 +1992,38 @@ async function obtainPlan(key, file, opts, cost, provider) {
1999
1992
  }
2000
1993
  return { plan: compiled.plan, cached: false };
2001
1994
  }
1995
+ // --- execution backend (local driver vs remote `vk server`) -----------------
1996
+ //
1997
+ // `vk ai`, `vk suite`, and `vk install` run their device work through an
1998
+ // ExecBackend. Local wraps one shared Driver; remote speaks HTTP to a `vk server`
1999
+ // beside the device (--server / VERIKUN_SERVER), where each validated leaf is ONE
2000
+ // round-trip (the auto-wait loop stays server-side). In remote mode the server
2001
+ // owns the device: its /v1/health platform+serial supersede the client's
2002
+ // --platform/--device, and no local driver is ever built.
2003
+ /**
2004
+ * Run a remote read; if it failed AND the server reported it moved device, ask once more.
2005
+ *
2006
+ * This is the ONE place a failed remote read is re-asked, and the narrowness is the point.
2007
+ * It is only ever wired to `preflight` — the connect probe at `vk ai`/`vk suite` startup and
2008
+ * the suite's between-tests health check — where nothing has run yet on either device. A
2009
+ * mid-flow read is never retried: the new device has none of the state the flow built up, so
2010
+ * its screen would answer a different question than the one being asked.
2011
+ *
2012
+ * Gating on the move (rather than retrying every failure) also keeps the connect probe's
2013
+ * fail-fast property: a device that is simply broken still fails on the first try.
2014
+ *
2015
+ * Exported solely so the unit suite can reach it.
2016
+ */
2017
+ async function retryAfterDeviceMove(read, moved) {
2018
+ try {
2019
+ return await read();
2020
+ }
2021
+ catch (e) {
2022
+ if (!moved())
2023
+ throw e;
2024
+ return read();
2025
+ }
2026
+ }
2002
2027
  /** The `--server` URL, or VERIKUN_SERVER. Exported-shape helper so `resolveBackend`
2003
2028
  * and `vk devices --server` can never disagree about what "remote" means. */
2004
2029
  function serverFromFlags(flags) {
@@ -2171,9 +2196,14 @@ async function resolveBackend(platform, device, flags) {
2171
2196
  },
2172
2197
  platform,
2173
2198
  device,
2199
+ moves: [],
2174
2200
  };
2175
2201
  }
2176
2202
  let runCtx = { platform, device };
2203
+ const moves = [];
2204
+ /** Set by the last move; the preflight below reads it to decide whether re-asking is
2205
+ * warranted, then clears it. */
2206
+ let movedDuringCall;
2177
2207
  const opts = {
2178
2208
  url: server,
2179
2209
  authKey: (0, args_1.flagStr)(flags, 'auth-key') || process.env.VERIKUN_SERVER_AUTH_KEY || undefined,
@@ -2181,6 +2211,19 @@ async function resolveBackend(platform, device, flags) {
2181
2211
  // is identical to a local run's. logStart travels from the server's device clock
2182
2212
  // so archive-time / vk log scoping works without a local driver.
2183
2213
  onStep: (step, artifacts, logStart) => run_1.Recorder.appendForeignStep(step, artifacts, { ...runCtx, logStart }),
2214
+ onDeviceChange: (c) => {
2215
+ moves.push(c);
2216
+ movedDuringCall = c;
2217
+ // Re-point the run context, so steps after the move are attributed to the device
2218
+ // that actually ran them. This makes `rolloverReason` seal the device-A run and
2219
+ // open a fresh one for B — intended: since a step is never replayed, no single run
2220
+ // can contain steps from two devices, and a report that claimed otherwise would lie.
2221
+ runCtx = { ...runCtx, device: c.to };
2222
+ (0, output_1.err)(`[verikun] server moved device: ${c.from} → ${c.to} (${c.reason})` +
2223
+ (c.retried
2224
+ ? ' — retried there'
2225
+ : ' — this step failed on the old device; the next runs on the new one'));
2226
+ },
2184
2227
  };
2185
2228
  let health = await (0, remote_1.pingServer)(opts); // fails fast (exit 3) on a bad URL or key
2186
2229
  // `--ensure-device` boots BEFORE runCtx is fixed: resolveBackend bakes the serial
@@ -2188,7 +2231,15 @@ async function resolveBackend(platform, device, flags) {
2188
2231
  // attribute every spliced step to a device that didn't exist yet.
2189
2232
  health = await ensureRemoteDevice(health, opts, server, flags);
2190
2233
  runCtx = { platform: health.platform, device: health.serial ?? undefined };
2191
- (0, output_1.err)(`[verikun] server ${server}: ${health.platform} · device ${health.serial ?? '(none)'} · verikun ${health.version}`);
2234
+ (0, output_1.err)(`[verikun] server ${server}: ${health.platform} · device ${health.serial ?? '(none)'} · verikun ${health.version}` +
2235
+ (health.failoverEnabled ? ' · failover: on' : ''));
2236
+ // A pool the server has already ruled out explains a lot of otherwise-baffling
2237
+ // behaviour ("why is it on THAT phone?"), so say it once, up front.
2238
+ if (health.quarantined?.length) {
2239
+ (0, output_1.err)(`[verikun] server has ruled out ${health.quarantined.length} device(s):`);
2240
+ for (const q of health.quarantined)
2241
+ (0, output_1.err)(`[verikun] ${q.serial} ${q.reason}`);
2242
+ }
2192
2243
  // Say the read path once, here. Reads execute server-side, so this is the only end of the
2193
2244
  // connection that knows it — and without it a companion that had silently stood down was
2194
2245
  // indistinguishable from one that never engaged, for a whole suite (issue #77). An older
@@ -2206,7 +2257,8 @@ async function resolveBackend(platform, device, flags) {
2206
2257
  // healthy and keep grinding. One dump is the cheap call that actually proves it.
2207
2258
  preflight: async () => {
2208
2259
  await (0, remote_1.pingServer)(opts);
2209
- await remote.getElements();
2260
+ movedDuringCall = undefined;
2261
+ await retryAfterDeviceMove(() => remote.getElements(), () => movedDuringCall !== undefined);
2210
2262
  },
2211
2263
  // Hierarchy only: the server exposes no screenshot route, so a remote run's
2212
2264
  // engine failure archives without a picture. Honest degrade over a protocol
@@ -2223,6 +2275,7 @@ async function resolveBackend(platform, device, flags) {
2223
2275
  platform: health.platform,
2224
2276
  device: health.serial ?? undefined,
2225
2277
  remote: { url: server, version: health.version, reads: health.reads },
2278
+ moves,
2226
2279
  };
2227
2280
  }
2228
2281
  /**
@@ -2462,8 +2515,7 @@ async function cmdAi(positionals, flags) {
2462
2515
  // otherwise an unattended run leaves the phone offline or in dark mode.
2463
2516
  await restoreDeviceOverrides(backend);
2464
2517
  await backend.close?.(); // frees a remote server's device lock for the next command
2465
- parkPreparedDevices(platform); // a prepped device goes back to sleep between runs
2466
- (0, claims_1.releaseOwnClaims)(); // and the host-level claim, so the next job can have the device
2518
+ (0, claims_1.releaseOwnClaims)(); // the host-level claim, so the next job can have the device
2467
2519
  }
2468
2520
  if ((0, args_1.flagBool)(flags, 'json')) {
2469
2521
  (0, output_1.json)({
@@ -2501,7 +2553,7 @@ async function cmdInstall(positionals, flags) {
2501
2553
  if (!(0, node_fs_1.existsSync)(path))
2502
2554
  throw new errors_1.CliError(`install: '${appPath}' does not exist`, 2);
2503
2555
  const platform = platformFromFlags(flags);
2504
- const { backend, remote } = await resolveBackend(platform, deviceFromFlags(flags, platform), flags);
2556
+ const { backend, remote, moves } = await resolveBackend(platform, deviceFromFlags(flags, platform), flags);
2505
2557
  (0, output_1.err)(`[verikun] installing ${appPath}${remote ? ` via ${remote.url}` : ''}…`);
2506
2558
  try {
2507
2559
  await backend.install(path);
@@ -2509,10 +2561,16 @@ async function cmdInstall(positionals, flags) {
2509
2561
  finally {
2510
2562
  await backend.close?.();
2511
2563
  }
2512
- if ((0, args_1.flagBool)(flags, 'json'))
2513
- (0, output_1.json)({ installed: appPath, ...(remote ? { server: remote.url } : {}) });
2514
- else
2515
- (0, output_1.out)(`installed ${appPath}`);
2564
+ // Where it LANDED, not just that it landed: after a failover that is a different
2565
+ // device than the one the run started against, and a caller acting on the old serial
2566
+ // (`adb -s … shell am start`) would be driving a phone without the build.
2567
+ const moved = moves.length ? moves[moves.length - 1] : undefined;
2568
+ if ((0, args_1.flagBool)(flags, 'json')) {
2569
+ (0, output_1.json)({ installed: appPath, ...(remote ? { server: remote.url } : {}), ...(moved ? { deviceChanged: moved } : {}) });
2570
+ }
2571
+ else {
2572
+ (0, output_1.out)(`installed ${appPath}${moved ? ` on ${moved.to}` : ''}`);
2573
+ }
2516
2574
  return 0;
2517
2575
  }
2518
2576
  // ---------------------------------------------------------------------------
@@ -2551,7 +2609,6 @@ async function cmdSuiteEntry(positionals, flags) {
2551
2609
  finally {
2552
2610
  await restoreDeviceOverrides(backend);
2553
2611
  await backend.close?.();
2554
- parkPreparedDevices(platform);
2555
2612
  (0, claims_1.releaseOwnClaims)();
2556
2613
  }
2557
2614
  }
@@ -2900,11 +2957,11 @@ DEVICE STATE (change the device the app runs on, then put it back)
2900
2957
  do this automatically when the flow ends OR fails,
2901
2958
  so a dead test can't leave the phone offline.
2902
2959
  device prep [--dry-run] [--json] Prepare a TEST device once, stickily: animations off,
2903
- display kept awake, Do Not Disturb on, battery idle off.
2904
- Survives the run (unlike \`device set\`), so it is undone
2905
- only by \`--revert\`. A PHYSICAL device must be named with
2906
- --device — prep must never land on a personal phone.
2907
- --no-sleep-when-idle keeps the screen on after a run.
2960
+ display timeout ${prep_1.PREP_SCREEN_TIMEOUT}, Do Not Disturb on,
2961
+ battery idle off. Survives the run (unlike \`device set\`),
2962
+ so it is undone only by \`--revert\`. A PHYSICAL device must
2963
+ be named with --device — prep must never land on a personal
2964
+ phone. --no-sleep-when-idle keeps the display on for good.
2908
2965
  device prep --revert [--dry-run] Put a prepared device back the way it was found
2909
2966
  device caps [--json] What this platform supports, and the manual
2910
2967
  equivalent where it doesn't
@@ -2961,7 +3018,7 @@ SUITE (run a directory of natural-language tests as one gated suite)
2961
3018
 
2962
3019
  SERVER (expose a locally-connected device to remote verikun clients)
2963
3020
  server [--bind addr] [--port n] [--auth-key k] [--allow-install]
2964
- [--allow-device-control[=names]]
3021
+ [--allow-device-control[=names]] [--allow-failover[=serials]|--no-failover]
2965
3022
  [--allow-unsafe-anonymous] Serve THIS machine's device over HTTP+JSON for
2966
3023
  \`vk ai/suite/install --server <url>\`. Only
2967
3024
  verikun's validated action grammar is runnable
@@ -2979,6 +3036,12 @@ SERVER (expose a locally-connected device to remote verikun clients)
2979
3036
  the flag the server also starts even when no device is attached, so a client can
2980
3037
  boot one: \`vk devices start|stop|restart [name] --server <url>\`, or add
2981
3038
  --ensure-device[=name] to ai/suite/install to boot once before the first step.
3039
+ Failover is ON by default: if the bound device cannot serve a request, the server
3040
+ moves to another attached, healthy, unclaimed one and rules the bad one out until it
3041
+ is power-cycled. An install is retried there; a mid-run step is NOT — it fails on the
3042
+ device it ran on, and the next request lands on the healthy one. Passing --device
3043
+ pins the binding and turns this off; --allow-failover[=serials] turns it back on (and
3044
+ bounds where it may go), --no-failover / VERIKUN_NO_FAILOVER disables it outright.
2982
3045
 
2983
3046
  ENVIRONMENT
2984
3047
  devices [--all] [--json] List attached devices/simulators, and which job is
@@ -31,7 +31,6 @@
31
31
  // Platform-agnostic by design, like `device/settings.ts` and `ui/`: it never touches
32
32
  // adb/xcrun. The drivers know which devices exist; this knows which are taken.
33
33
  Object.defineProperty(exports, "__esModule", { value: true });
34
- exports.ownClaimedSerials = ownClaimedSerials;
35
34
  exports.releaseOwnClaims = releaseOwnClaims;
36
35
  exports.setProcessScoped = setProcessScoped;
37
36
  exports.claimsEnabled = claimsEnabled;
@@ -73,11 +72,6 @@ let processScoped = false;
73
72
  * store what it took is the only way it can release anything.
74
73
  */
75
74
  const acquired = new Set();
76
- /** Which devices this process took. Read by the prep teardown, which has to act on them
77
- * BEFORE `releaseOwnClaims` empties this — see `parkPreparedDevices` in cli.ts. */
78
- function ownClaimedSerials() {
79
- return [...acquired];
80
- }
81
75
  /** Give back every device this process claimed. Best-effort; teardown must never throw. */
82
76
  function releaseOwnClaims(o = {}) {
83
77
  const released = [];