verikun 0.27.1 → 0.28.0

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.
@@ -530,10 +530,12 @@ device that failed and is now on another one. What that means depends on the lin
530
530
  the failure was real on A, and B has none of the state your flow built up. Start the
531
531
  flow again from the top if you want it on B.
532
532
 
533
- The server rules the bad device out until it is power-cycled;
534
- `vk devices --server <url>` shows why in its `NOTE` column. On a pool the replacement
535
- joins the pool, so capacity holds and the last device is never shed, so its own error
536
- keeps reaching you rather than a bare "no device attached".
533
+ The server rules the bad device out; `vk devices --server <url>` shows why in its `NOTE`
534
+ column, and a pooled server re-adopts a device that comes back within a minute. A device
535
+ that is still attached keeps its place and is simply dealt last, so its own error keeps
536
+ reaching you rather than a bare "no device attached". A device that is **gone** leaves the
537
+ pool — so `capacity` can drop mid-job, and an install can come back `exit 0` having skipped
538
+ it. That is a success: nothing can be dealt a device running the previous build.
537
539
 
538
540
  ## The device is missing or wedged
539
541
 
package/CHANGELOG.md CHANGED
@@ -6,6 +6,27 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.28.0] - 2026-09-14
10
+
11
+ A device that has gone now leaves a pooled `vk server` instead of being dealt out until
12
+ somebody notices.
13
+
14
+ ### Fixed
15
+ - **`vk server --devices`** drops a device that is gone from the pool instead of dealing it
16
+ forever; the sweep readmits it when it returns. ([#139])
17
+ - **`vk install --server`** exits `0` when some pooled devices take the build, naming the rest
18
+ as `skipped`; only a build that fails everywhere is an error. ([#139])
19
+ - **A `--server` call that outlives Node's 300s fetch ceiling** now says so, and says the clock
20
+ was the client's, instead of a bare `fetch failed` the suite read as a dead device. ([#139])
21
+
22
+ ### Changed
23
+ - **`/v1/health` `capacity` can now drop mid-job** on a pooled server, to `0`. A plain
24
+ `vk server` still keeps its only device and answers with that device's own error. ([#139])
25
+ - **`POST /v1/install`** answers `200 {devices, skipped}` where it used to answer `500` on a
26
+ partial failure. Older clients see a success with an unknown field. ([#139])
27
+
28
+ [#139]: https://github.com/ddikman/verikun/issues/139
29
+
9
30
  ## [0.27.1] - 2026-09-14
10
31
 
11
32
  A hierarchy read the device killed is now waited out instead of ending the test.
@@ -10,6 +10,7 @@
10
10
  // local one. Recording stays a caller concern: this module never touches ./.verikun.
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.describeStatus = describeStatus;
13
+ exports.transportReason = transportReason;
13
14
  exports.pingServer = pingServer;
14
15
  exports.remoteDeviceOp = remoteDeviceOp;
15
16
  exports.remoteDeviceList = remoteDeviceList;
@@ -18,16 +19,36 @@ const node_fs_1 = require("node:fs");
18
19
  const node_crypto_1 = require("node:crypto");
19
20
  const node_path_1 = require("node:path");
20
21
  const errors_1 = require("../errors");
22
+ const output_1 = require("../output");
21
23
  const rpc_1 = require("../rpc");
24
+ /**
25
+ * The ceiling NONE of the per-call timeouts below can exceed, whatever they say.
26
+ *
27
+ * Node's global `fetch` is undici, whose `headersTimeout` and `bodyTimeout` both default to
28
+ * 300s, and there is no dependency-free way to raise them: a `dispatcher` needs `undici`
29
+ * itself, which is bundled but not importable. The `AbortController` below is therefore a
30
+ * FLOOR on how long a call may take, never a ceiling — `EXEC_TIMEOUT_MS` says 600s and gets
31
+ * 300s.
32
+ *
33
+ * MEASURED on Node v20.20.2 against a server that held its headers for 310s: the fetch
34
+ * rejected at 301s with `TypeError: fetch failed`, cause `HeadersTimeoutError`, code
35
+ * `UND_ERR_HEADERS_TIMEOUT`. The bare `fetch failed` is the whole problem — `describeStatus`
36
+ * never sees it, the suite reads the resulting exit 3 as the DEVICE being unreachable, and a
37
+ * healthy phone gets retired for a client-side clock. Named in `request` below so it says so.
38
+ */
39
+ const FETCH_HEADERS_CEILING_MS = 300_000;
22
40
  // Per-call ceilings. exec is generous: a single leaf may legitimately block for its
23
- // whole auto-wait window or an explicit `wait --timeout`, plus device time.
41
+ // whole auto-wait window or an explicit `wait --timeout`, plus device time. Anything here
42
+ // above FETCH_HEADERS_CEILING_MS is aspirational — see that constant.
24
43
  const HEALTH_TIMEOUT_MS = 10_000;
25
44
  const ELEMENTS_TIMEOUT_MS = 60_000;
26
45
  const EXEC_TIMEOUT_MS = 10 * 60_000;
27
46
  const INSTALL_TIMEOUT_MS = 15 * 60_000;
28
47
  const DEVICE_LIST_TIMEOUT_MS = 30_000;
29
- // Above the server's own 4-minute boot ceiling, so the SERVER reports why a boot
30
- // timed out rather than the client aborting into a generic "timed out after 300s".
48
+ // Meant to sit above the server's own 4-minute boot ceiling, so the SERVER reports why a
49
+ // boot timed out rather than the client aborting first. It is exactly AT
50
+ // FETCH_HEADERS_CEILING_MS, so a boot that runs the full four minutes and then some is a
51
+ // photo finish — which is survivable only because `transportReason` now names the loser.
31
52
  const DEVICE_START_TIMEOUT_MS = 5 * 60_000;
32
53
  const DEVICE_STOP_TIMEOUT_MS = 60_000;
33
54
  const trimUrl = (url) => url.replace(/\/+$/, '');
@@ -66,6 +87,28 @@ function describeStatus(status, body, url) {
66
87
  }
67
88
  return new errors_1.CliError(`verikun server error ${status} at ${url}${detail}`, exitCode);
68
89
  }
90
+ /**
91
+ * Why the transport failed, in words an operator can act on. PURE — exported for the tests.
92
+ *
93
+ * The undici arm is the one that earns its keep. `fetch` reports its own header/body
94
+ * timeouts as a bare `TypeError: fetch failed` with the real cause one level down, and that
95
+ * string is indistinguishable from a server that is genuinely unreachable — which is how a
96
+ * five-minute install came to look like a dead phone, and how a lane came to be retired for
97
+ * it. Say which clock ran out, and say whose it was.
98
+ */
99
+ function transportReason(e, timeoutMs) {
100
+ const ex = e;
101
+ if (ex?.name === 'AbortError')
102
+ return `timed out after ${Math.round(timeoutMs / 1000)}s`;
103
+ const code = ex?.cause?.code;
104
+ if (code === 'UND_ERR_HEADERS_TIMEOUT' || code === 'UND_ERR_BODY_TIMEOUT') {
105
+ const what = code === 'UND_ERR_HEADERS_TIMEOUT' ? 'send a response' : 'finish its response';
106
+ return (`the server did not ${what} within ${Math.round(FETCH_HEADERS_CEILING_MS / 1000)}s — ` +
107
+ "this is Node's own fetch ceiling on the CLIENT, not the device. " +
108
+ 'The server may still be working; check its log before blaming the device');
109
+ }
110
+ return ex?.message ?? String(e);
111
+ }
69
112
  async function readBody(res) {
70
113
  try {
71
114
  return (await res.json());
@@ -103,8 +146,7 @@ class RemoteTransport {
103
146
  });
104
147
  }
105
148
  catch (e) {
106
- const reason = e.name === 'AbortError' ? `timed out after ${Math.round(timeoutMs / 1000)}s` : e.message;
107
- throw new errors_1.CliError(`cannot reach verikun server at ${url} (${reason})`, 3);
149
+ throw new errors_1.CliError(`cannot reach verikun server at ${url} (${transportReason(e, timeoutMs)})`, 3);
108
150
  }
109
151
  finally {
110
152
  clearTimeout(timer);
@@ -229,6 +271,14 @@ function createRemoteBackend(opts, health) {
229
271
  // the build DID land — on a different device than the one we started with.
230
272
  if (res.deviceChanged)
231
273
  opts.onDeviceChange?.(res.deviceChanged);
274
+ // A PARTIAL install is a success, and it must not be a silent one: capacity just
275
+ // dropped, and the operator's next question is which phone to go and look at.
276
+ if (res.skipped?.length) {
277
+ (0, output_1.err)(`[verikun] server installed on ${(res.devices ?? []).join(', ') || '(none)'}; ` +
278
+ `${res.skipped.length} device(s) could not take this build and left the pool — ` +
279
+ res.skipped.map((s) => `${s.serial} (${s.reason})`).join('; '));
280
+ opts.onInstallSkipped?.(res.skipped);
281
+ }
232
282
  },
233
283
  async reset(appId) {
234
284
  // Between-test housekeeping (vk suite): the step is deliberately NOT spliced
package/dist/cli.js CHANGED
@@ -2269,10 +2269,12 @@ async function resolveBackend(platform, device, flags) {
2269
2269
  // every recorded command, which beats a timer: it fires when work happens.
2270
2270
  grant: (0, grant_1.processClaimGrant)(device, claims_1.releaseOwnClaims),
2271
2271
  moves: [],
2272
+ skipped: [],
2272
2273
  };
2273
2274
  }
2274
2275
  let runCtx = { platform, device };
2275
2276
  const moves = [];
2277
+ const skipped = [];
2276
2278
  /** Set by the last move; the preflight below reads it to decide whether re-asking is
2277
2279
  * warranted, then clears it. */
2278
2280
  let movedDuringCall;
@@ -2283,6 +2285,7 @@ async function resolveBackend(platform, device, flags) {
2283
2285
  // is identical to a local run's. logStart travels from the server's device clock
2284
2286
  // so archive-time / vk log scoping works without a local driver.
2285
2287
  onStep: (step, artifacts, logStart) => run_1.Recorder.appendForeignStep(step, artifacts, { ...runCtx, logStart }),
2288
+ onInstallSkipped: (s) => skipped.push(...s),
2286
2289
  onDeviceChange: (c) => {
2287
2290
  moves.push(c);
2288
2291
  movedDuringCall = c;
@@ -2361,6 +2364,7 @@ async function resolveBackend(platform, device, flags) {
2361
2364
  grant: (0, grant_1.leaseGrant)(remote, serial),
2362
2365
  remote: { url: server, version: health.version, reads },
2363
2366
  moves,
2367
+ skipped,
2364
2368
  };
2365
2369
  }
2366
2370
  /**
@@ -2659,7 +2663,7 @@ async function cmdInstall(positionals, flags) {
2659
2663
  if (!(0, node_fs_1.existsSync)(path))
2660
2664
  throw new errors_1.CliError(`install: '${appPath}' does not exist`, 2);
2661
2665
  const platform = platformFromFlags(flags);
2662
- const { backend, remote, moves } = await resolveBackend(platform, deviceFromFlags(flags, platform), flags);
2666
+ const { backend, remote, moves, skipped } = await resolveBackend(platform, deviceFromFlags(flags, platform), flags);
2663
2667
  (0, output_1.err)(`[verikun] installing ${appPath}${remote ? ` via ${remote.url}` : ''}…`);
2664
2668
  try {
2665
2669
  await backend.install(path);
@@ -2676,11 +2680,22 @@ async function cmdInstall(positionals, flags) {
2676
2680
  // device than the one the run started against, and a caller acting on the old serial
2677
2681
  // (`adb -s … shell am start`) would be driving a phone without the build.
2678
2682
  const moved = moves.length ? moves[moves.length - 1] : undefined;
2683
+ // A pooled server may have installed on some devices and dropped the rest. That is a
2684
+ // SUCCESS — the ones that missed the build are no longer leasable, so no later lane can
2685
+ // run the previous build and report green — but it is not a silent one: capacity changed.
2679
2686
  if ((0, args_1.flagBool)(flags, 'json')) {
2680
- (0, output_1.json)({ installed: appPath, ...(remote ? { server: remote.url } : {}), ...(moved ? { deviceChanged: moved } : {}) });
2687
+ (0, output_1.json)({
2688
+ installed: appPath,
2689
+ ...(remote ? { server: remote.url } : {}),
2690
+ ...(moved ? { deviceChanged: moved } : {}),
2691
+ ...(skipped.length ? { skipped } : {}),
2692
+ });
2681
2693
  }
2682
2694
  else {
2683
2695
  (0, output_1.out)(`installed ${appPath}${moved ? ` on ${moved.to}` : ''}`);
2696
+ if (skipped.length) {
2697
+ (0, output_1.out)(`skipped ${skipped.length} device(s), now out of the pool: ${skipped.map((s) => s.serial).join(', ')}`);
2698
+ }
2684
2699
  }
2685
2700
  return 0;
2686
2701
  }
package/dist/server.js CHANGED
@@ -519,7 +519,26 @@ function buildServer(config) {
519
519
  : null;
520
520
  // Like the reconcile timer: must never hold the process open at Ctrl-C.
521
521
  recycleTimer?.unref?.();
522
- const pickFailoverDevice = (failed, reason) => serializeFailover(() => pickFailoverDeviceLocked(failed, reason));
522
+ /**
523
+ * Is this failure grounds to REMOVE the device from the pool, rather than merely deal it
524
+ * last? Two conditions, and both are necessary.
525
+ *
526
+ * `unreachable` is the only kind that qualifies, because it is the only one that says the
527
+ * device is not there. Every other kind describes a device that is present and unhappy —
528
+ * a full disk, a wedged app, an exit 3 nobody has classified — and for those, demotion
529
+ * plus recovery-by-traffic is right and this must not change: they can still produce the
530
+ * traffic that clears them. An absent device cannot, which is the whole defect (#139): the
531
+ * demotion is a sort key (`leaseFor`), so "dealt last" is still dealt, every round, and
532
+ * `restoreDevice` can never fire for a device that will never answer again.
533
+ *
534
+ * And only on a POOLED server, because only a pooled server sweeps. `reconcileOnce`
535
+ * returns immediately without `poolSpec` (`wantedSerials`) and its timer is never even
536
+ * created — see `ServerConfig.poolSpec`, "deliberately does not reconcile". Shedding
537
+ * where nothing readmits would trade a device that fails loudly for a server that is
538
+ * empty until someone restarts it: a worse failure, and a new one.
539
+ */
540
+ const shedOnFailure = (kind) => kind === 'unreachable' && config.poolSpec !== undefined;
541
+ const pickFailoverDevice = (failed, reason, kind) => serializeFailover(() => pickFailoverDeviceLocked(failed, reason, kind));
523
542
  /**
524
543
  * Bring in a healthy replacement for `failed`. Returns the serial moved to, or null
525
544
  * when none remains (which is not an error here — the caller reports the ORIGINAL
@@ -533,7 +552,7 @@ function buildServer(config) {
533
552
  * only reports ready once its OWN `preflight()` has passed, so starting the worker IS
534
553
  * the probe, run on the thread that will go on to use it.
535
554
  */
536
- const pickFailoverDeviceLocked = async (failed, reason) => {
555
+ const pickFailoverDeviceLocked = async (failed, reason, kind) => {
537
556
  const policy = config.failover;
538
557
  if (!policy)
539
558
  return null;
@@ -549,23 +568,29 @@ function buildServer(config) {
549
568
  // see and where the server will actually go cannot drift. A pool member's own driver
550
569
  // is not: it may be pointed at a corpse.
551
570
  /**
552
- * Nothing healthier exists. Shed the failed device — continuing to hand it out is
553
- * what makes a pool a coin flip per lease — but ONLY while another remains.
571
+ * Nothing healthier exists. Decide what becomes of the failed device itself THREE
572
+ * outcomes, not two, and which one applies is `shedOnFailure`'s question:
554
573
  *
555
- * The LAST device stays, deliberately. A server that shed it would answer every
556
- * later request `503 no device attached`, replacing the device's own error (full
557
- * disk, no space, whatever it actually was) with a message that names nothing. A
558
- * caller stuck on one broken device is better served by the truth about it.
574
+ * - GONE, on a pooled server shed it. It cannot serve and cannot recover by
575
+ * traffic, so leaving it in the pool means dealing it forever (#139). The sweep
576
+ * owns readmission, so capacity comes back on its own.
577
+ * - present but unhappy demote it: worker, claim and slot kept, dealt last,
578
+ * restored by the first command that works.
579
+ * - already left on its own (its worker died) — nothing to remove, just clean up.
580
+ *
581
+ * The last two share a tail with the first, because "stop serving this device" has the
582
+ * same consequences however it came about.
559
583
  */
560
584
  const shrink = async () => {
561
585
  // A device whose worker DIED is already out of the pool, so there is nothing left to
562
586
  // shed — but its holder still has to be evicted and its claim and companion handed
563
- // back, and the "last device stays" guard below must not skip that. Asking whether
564
- // it is still a member is what separates the two cases.
587
+ // back. Asking whether it is still a member is what separates that case from a
588
+ // device we are removing ourselves.
565
589
  const serving = pool.serials().includes(failed);
566
- if (serving) {
567
- // DEMOTE, never shed. The device keeps its worker, its claim and its place in the
568
- // pool; it is simply dealt last until it does some work (see `degradeDevice`).
590
+ if (serving && !shedOnFailure(kind)) {
591
+ // DEMOTE the device is still THERE. It keeps its worker, its claim and its place
592
+ // in the pool; it is simply dealt last until it does some work (see
593
+ // `degradeDevice`). Contrast the shed below, which is only for a device that is not.
569
594
  //
570
595
  // This replaces "nothing healthier to move to — X left the pool". That rule read
571
596
  // correctly on a SINGLE-device server, where it never actually fired (the last
@@ -573,10 +598,16 @@ function buildServer(config) {
573
598
  // verdict — because a pool's own members are excluded from its candidate list, so
574
599
  // "no candidate" is the normal case rather than the exceptional one. The argument
575
600
  // for shedding was that continuing to hand out a broken device makes a pool a coin
576
- // flip per lease; that is answered by ORDERING (a degraded device is chosen only
577
- // when nothing else is free), which costs no capacity. And it is the same judgement
578
- // the last-device branch already made out loud: a caller stuck on one broken device
579
- // is better served by the truth about it than by a server that quietly halved.
601
+ // flip per lease; for a device that is PRESENT that is answered by ORDERING (a
602
+ // degraded device is chosen only when nothing else is free), which costs no
603
+ // capacity, and a caller that does reach it is better served by the truth about it
604
+ // than by a server that quietly halved.
605
+ //
606
+ // Ordering answers it only while the device can still come back, though. It cannot
607
+ // answer for a device that is GONE — "dealt last" is still dealt once the healthy
608
+ // devices are busy, which on a suite sized to the pool is every round, and no
609
+ // amount of ordering produces the traffic `restoreDevice` needs. That case is
610
+ // shed above, by `shedOnFailure`.
580
611
  //
581
612
  // The holder keeps its lease too: its device did not go anywhere, so there is no
582
613
  // `deviceChanged` to send and nothing for the run to seal. The step that failed
@@ -585,7 +616,28 @@ function buildServer(config) {
585
616
  degradeDevice(failed, reason);
586
617
  return null;
587
618
  }
588
- // NOT serving — its worker already died, so the device left on its own and there is
619
+ if (serving) {
620
+ // SHED. The device is gone and this server sweeps, so removing it is not the
621
+ // one-way ratchet it was before the sweep existed (#114): `reconcileOnce` lists it
622
+ // as missing from what `--devices` asked for, retries with backoff, and
623
+ // `rejoinDevice` readmits it — bringing it up to `lastInstall` first — the moment
624
+ // it answers again. Capacity returns without anyone restarting anything.
625
+ //
626
+ // It keeps its QUARANTINE, unlike the demote branch above, and that asymmetry is
627
+ // the point: quarantine means "not serving, and ruled out", degradation means
628
+ // "serving but suspect", and the two are disjoint precisely so `/v1/health` and
629
+ // `exhaustedNote` can be read. A shed device genuinely is not serving, so it
630
+ // belongs in the same list as one whose worker died — which is the tail below,
631
+ // reached from here. `rejoinDevice` clears it on evidence, never on a clock.
632
+ //
633
+ // `degraded` must be given up though: it is defined as pool MEMBERS that recently
634
+ // failed, and a non-member left in it would have `/v1/health` reporting a device it
635
+ // no longer serves, in a list whose whole meaning is that it still does.
636
+ pool.retire(failed);
637
+ degraded.delete(failed);
638
+ (0, output_1.err)(`[server] pool: ${failed} left the pool — ${reason} (the sweep readmits it when it answers again)`);
639
+ }
640
+ // NOT serving — its worker died, or the shed above just removed it, so there is
589
641
  // nothing to demote. The holder is EVICTED, not migrated: without a replacement there
590
642
  // is no `deviceChanged` to send, so the client never learns to seal its run — and
591
643
  // merely dropping the lease would let its next request silently draw some other device
@@ -690,7 +742,14 @@ function buildServer(config) {
690
742
  * Is this device actually gone? Two probes a second apart, because that gap is the only
691
743
  * thing separating a USB re-enumeration or a mid-`launch --clear` gap from a dead box —
692
744
  * and quarantining a healthy device is the expensive mistake here. Returns the reason
693
- * when dead, undefined when it was a blip.
745
+ * AND the probe's own verdict kind when dead, undefined when it was a blip.
746
+ *
747
+ * The kind is carried out because the probe is often the better-classified of the two
748
+ * failures. The operation that brought us here may have failed with a string nothing
749
+ * recognises (an unclassified exit 3, which is what earns a probe in the first place),
750
+ * while `preflight` on a detached phone says `device '<serial>' not found` — the exact
751
+ * `UNREACHABLE_RULES` wording. Reporting the ORIGINAL verdict's kind there would decide
752
+ * "shed or demote" from the vaguer of two answers about the same device.
694
753
  */
695
754
  const deviceIsDead = async (handle) => {
696
755
  let last = '';
@@ -722,7 +781,7 @@ function buildServer(config) {
722
781
  (0, output_1.err)(`[server] probe on ${handle.serial}: ${verdict.reason} (${verdict.kind}) — a host problem, not this device`);
723
782
  return undefined;
724
783
  }
725
- return last || 'the device stopped answering';
784
+ return { reason: last || 'the device stopped answering', kind: verdict.kind };
726
785
  };
727
786
  /**
728
787
  * A non-install operation failed. Move off the device if it is genuinely at fault —
@@ -761,6 +820,7 @@ function buildServer(config) {
761
820
  try {
762
821
  const verdict = (0, failover_1.classifyFailure)(e);
763
822
  let reason = verdict.reason;
823
+ let kind = verdict.kind;
764
824
  if (!verdict.move) {
765
825
  // Only an unrecognised exit 3 earns a probe; `transient` and `toolchain` set
766
826
  // probe:false precisely so a mid-launch gap or a missing adb cannot become a move.
@@ -777,15 +837,21 @@ function buildServer(config) {
777
837
  (0, output_1.err)(`[server] ${what}: ${from} failed but probes healthy — staying (${verdict.reason})`);
778
838
  return undefined; // a blip — the test rerun is the right answer, not a new device
779
839
  }
780
- reason = dead;
840
+ reason = dead.reason;
841
+ // The PROBE's verdict, not the original failure's. We are here because the
842
+ // operation failed with something nothing recognised; `preflight` on a detached
843
+ // phone says `device '<serial>' not found`, which is classified. Taking the vaguer
844
+ // of two answers about the same device is how a detachment that first showed up as
845
+ // an odd exit 3 would be demoted forever instead of shed.
846
+ kind = dead.kind;
781
847
  noteVerdict({ ...verdict, move: true }, e, what);
782
848
  }
783
849
  (0, output_1.err)(`[server] ${what}: FAILED on ${from} — ${reason}`);
784
850
  quarantineDevice(from, reason);
785
- // pickFailoverDevice has already said which of the two no-move outcomes happened —
786
- // the device was shed, or it was the last one and stayed. A second line here would
787
- // contradict one of them.
788
- const to = await pickFailoverDevice(from, reason);
851
+ // pickFailoverDevice has already said which no-move outcome happened — the device
852
+ // was shed, or demoted, or had already left. A second line here would contradict
853
+ // one of them.
854
+ const to = await pickFailoverDevice(from, reason, kind);
789
855
  if (!to)
790
856
  return undefined;
791
857
  return { from, to, reason, retried: false };
@@ -958,6 +1024,9 @@ function buildServer(config) {
958
1024
  return new server_http_1.HttpError(409, 'the device this run was using left the pool and nothing healthy replaced it — ' +
959
1025
  'start a fresh run; this one cannot continue on another device', 3);
960
1026
  }
1027
+ // An empty pool never reaches here: the deviceless guard in the router answers 503 for
1028
+ // every route that takes a lease, and it names `lostDevice` while doing it. So `n` is
1029
+ // always >= 1 and this only ever describes CONTENTION, which is what 409 means.
961
1030
  const n = pool.serials().length;
962
1031
  return new server_http_1.HttpError(409, n > 1
963
1032
  ? `all ${n} devices are leased by other active runs — retry when one finishes`
@@ -1197,9 +1266,9 @@ function buildServer(config) {
1197
1266
  * wrapper keeps no result on a throw), so dropping it leaves the operator holding a
1198
1267
  * serial the server has already left.
1199
1268
  */
1200
- const hopOrThrow = async (why, giveUp) => {
1269
+ const hopOrThrow = async (why, giveUp, kind) => {
1201
1270
  quarantineDevice(from, why);
1202
- const to = await pickFailoverDevice(from, why);
1271
+ const to = await pickFailoverDevice(from, why, kind);
1203
1272
  if (to === null) {
1204
1273
  // A pool that emptied with nothing having moved keeps its own 503 — a more
1205
1274
  // accurate status than a wrapped 500.
@@ -1224,7 +1293,9 @@ function buildServer(config) {
1224
1293
  const gone = firstError ?? new server_http_1.HttpError(503, `device ${from} is no longer attached`, 3);
1225
1294
  if (!config.failover || hop >= MAX_FAILOVER_HOPS)
1226
1295
  throw gone;
1227
- from = await hopOrThrow('the device left the pool mid-install', gone);
1296
+ // `unreachable` is the literal truth — it is not in the pool and it is also
1297
+ // inert here: `shrink` sees a non-member and takes its cleanup tail either way.
1298
+ from = await hopOrThrow('the device left the pool mid-install', gone, 'unreachable');
1228
1299
  continue;
1229
1300
  }
1230
1301
  try {
@@ -1241,7 +1312,7 @@ function buildServer(config) {
1241
1312
  // hops: report the first failure unchanged, exactly as before this feature.
1242
1313
  if (!verdict.move || !config.failover || hop >= MAX_FAILOVER_HOPS)
1243
1314
  throw firstError;
1244
- from = await hopOrThrow(verdict.reason, firstError);
1315
+ from = await hopOrThrow(verdict.reason, firstError, verdict.kind);
1245
1316
  }
1246
1317
  }
1247
1318
  }
@@ -1300,6 +1371,12 @@ function buildServer(config) {
1300
1371
  }));
1301
1372
  const failed = outcomes.filter((o) => o.error);
1302
1373
  const moved = outcomes.filter((o) => o.change);
1374
+ // Where each outcome ENDED UP. An install that moved ran on its replacement, not on
1375
+ // the serial it started from, so the device that holds this build — or conspicuously
1376
+ // does not — is the last one it was on, never `o.serial`.
1377
+ const landedOn = (o) => o.change?.to ?? o.serial;
1378
+ const installed = outcomes.filter((o) => !o.error).map(landedOn);
1379
+ let skipped = [];
1303
1380
  if (failed.length) {
1304
1381
  // One artifact, many devices: if it failed everywhere the file is the suspect, so
1305
1382
  // surface the FIRST device's error unchanged rather than a summary that buries it.
@@ -1316,26 +1393,69 @@ function buildServer(config) {
1316
1393
  }
1317
1394
  throw failed[0].error;
1318
1395
  }
1319
- // Carry a move that DID happen even though the overall install failed: the
1320
- // client re-points its run context on `deviceChanged`, and dropping it here
1321
- // would leave the operator holding a serial the server has already left.
1322
- throw new server_http_1.HttpError(500, `install failed on ${failed.map((f) => `${f.serial} (${(0, server_http_1.firstLine)(f.error.message)})`).join('; ')}`, 3, moved[0]?.change);
1396
+ // PARTIAL. Two healthy phones took the build and one did not. Answering 500 for the
1397
+ // whole pool is what turned one detached device into a dead CI job (#139) — and it
1398
+ // dies at the install step, so the run has already paid for an app build and tested
1399
+ // nothing.
1400
+ //
1401
+ // What made the 500 defensible is the fan-out's own rule, one line up: a lane dealt
1402
+ // a device that missed this build runs the PREVIOUS one and reports green, which is
1403
+ // the worst result this server can produce. The answer is not to soften that rule
1404
+ // but to SATISFY it — a device that did not take the build leaves the pool, so no
1405
+ // lease can reach it. `rejoinDevice` already makes exactly this call out loud
1406
+ // ("serving the wrong build is worse than not serving") and offers the same remedy:
1407
+ // the sweep readmits it and installs `lastInstall` before it is dealt any work.
1408
+ //
1409
+ // Done regardless of `config.failover`. The kill switch governs MOVING BETWEEN
1410
+ // devices; it was never a licence to serve a stale build, and the sweep that brings
1411
+ // the device back is gated on `poolSpec`, not on failover.
1412
+ skipped = failed.map((f) => {
1413
+ const serial = landedOn(f);
1414
+ const reason = (0, server_http_1.firstLine)(f.error.message);
1415
+ if (pool.serials().includes(serial)) {
1416
+ pool.retire(serial);
1417
+ // Same two rules as the shed in `shrink`: `degraded` is for MEMBERS, and a
1418
+ // device that is not serving belongs in `quarantine` — which `rejoinDevice`
1419
+ // clears on the evidence of a worker that started and a build that installed.
1420
+ degraded.delete(serial);
1421
+ quarantineDevice(serial, `did not take the current build — ${reason}`);
1422
+ evictHoldersOf(serial, `${serial} left the pool without the current build`);
1423
+ (0, manager_1.releaseCompanionOn)(serial);
1424
+ if ((0, claims_1.claimsEnabled)(claimEnv))
1425
+ (0, claims_1.releaseClaim)(serial, { ...claimOpts, mineOnly: true });
1426
+ }
1427
+ return { serial, reason };
1428
+ });
1429
+ (0, output_1.err)(`[server] install: partial — ${installed.join(', ')} took the build; ` +
1430
+ `removed from the pool: ${skipped.map((s) => `${s.serial} (${s.reason})`).join('; ')}`);
1323
1431
  }
1324
1432
  for (const m of moved)
1325
1433
  (0, output_1.err)(`[server] install: ${m.serial} → ${m.change.to} (${m.moves} move(s))`);
1326
- (0, output_1.err)(`[server] install: done on ${pool.serials().join(', ')}`);
1434
+ (0, output_1.err)(`[server] install: done on ${installed.join(', ')}`);
1327
1435
  // Retain the artifact so a device that rejoins later can be brought up to this build
1328
1436
  // (see `rejoinDevice`). Renamed out of the per-request temp name into one stable slot,
1329
1437
  // so at most one build is ever held and each install replaces the last.
1438
+ //
1439
+ // Reached on a PARTIAL install too, and load-bearing there: the devices just removed
1440
+ // are precisely the ones the sweep will readmit, and `rejoinDevice` brings a returning
1441
+ // device up to `lastInstall`. Retaining only on a clean sweep would hand each of them
1442
+ // the PREVIOUS build on the way back in — and `rejoinDevice`'s own check would pass,
1443
+ // because an install that succeeds is all it can see.
1330
1444
  retainInstall(tmpPath, ext);
1331
1445
  retained = true;
1446
+ // Only a move whose destination SURVIVED is worth reporting. The client re-points its
1447
+ // run context on `deviceChanged`, so naming a device the partial branch retired three
1448
+ // lines ago would send its next step to a serial this server no longer serves.
1449
+ const survivors = new Set(pool.serials());
1450
+ const reportableMove = moved.map((m) => m.change).find((c) => survivors.has(c.to));
1332
1451
  const body = {
1333
1452
  ok: true,
1334
1453
  bytes: size,
1335
1454
  sha256: digest,
1336
- devices: pool.serials(),
1455
+ devices: installed,
1456
+ ...(skipped.length ? { skipped } : {}),
1337
1457
  // The wire field is singular; a pool that moved more than one device logs the rest.
1338
- ...(moved.length ? { deviceChanged: moved[0].change } : {}),
1458
+ ...(reportableMove ? { deviceChanged: reportableMove } : {}),
1339
1459
  };
1340
1460
  (0, server_http_1.sendJson)(res, 200, body);
1341
1461
  }
@@ -1360,10 +1480,12 @@ function buildServer(config) {
1360
1480
  * phone another job is mid-test on. Refusing plainly beats a rule nobody can predict.
1361
1481
  * The GET listing stays available, because reading what is attached is safe.
1362
1482
  *
1363
- * KNOWN COST, stated in the refusal so nobody has to discover it: this is also the only
1364
- * thing that clears a quarantine, so on a pool a device ruled out by failover stays out
1365
- * until the server is restarted. That is the price of refusing rather than guessing;
1366
- * lifting it would mean letting a NAMED, allowlisted target act on one pool member.
1483
+ * This used to carry a known cost that it was the ONLY thing clearing a quarantine, so
1484
+ * on a pool a device ruled out by failover stayed out until the server was restarted.
1485
+ * That is no longer true and the refusal no longer says it: `rejoinDevice` clears the
1486
+ * quarantine (and `degraded`, and `failedOver`) when the sweep readmits a device, on the
1487
+ * evidence of a worker that started and a build that installed. The refusal itself stands
1488
+ * — power-cycling one member of a pool another job is mid-test on is what it prevents.
1367
1489
  */
1368
1490
  function requireSingleDevice(op) {
1369
1491
  const n = pool.serials().length;
package/dist/version.js CHANGED
@@ -3,4 +3,4 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.VERSION = void 0;
4
4
  // GENERATED by scripts/gen-version.mjs from package.json's "version" at build time
5
5
  // (the `prebuild` script). Do NOT edit by hand; bump package.json instead.
6
- exports.VERSION = '0.27.1';
6
+ exports.VERSION = '0.28.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "verikun",
3
- "version": "0.27.1",
3
+ "version": "0.28.0",
4
4
  "description": "Drive Android emulators/devices and iOS simulators for AI agents: tap, type, swipe, screenshot, and inspect the UI hierarchy by semantic identifiers — like Puppeteer for native apps.",
5
5
  "keywords": [
6
6
  "android",