@vzn/vx 0.0.646 → 0.0.648

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.
Files changed (184) hide show
  1. package/README.md +29 -22
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +112 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +18 -2
  12. package/src/bin.ts +53 -12
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +268 -53
  15. package/src/cache/git-inputs.ts +219 -87
  16. package/src/cache/index.ts +3 -2
  17. package/src/cache/inputs.ts +460 -78
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +49 -9
  20. package/src/cache/layered-cache.ts +123 -58
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +19 -3
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +90 -14
  26. package/src/cache/run-history.ts +70 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +25 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/docs.ts +42 -16
  34. package/src/cli/help.ts +58 -19
  35. package/src/cli/index.ts +79 -33
  36. package/src/cli/info.ts +12 -6
  37. package/src/cli/init.ts +186 -31
  38. package/src/cli/last.ts +439 -45
  39. package/src/cli/lock.ts +197 -52
  40. package/src/cli/plan-format.ts +91 -49
  41. package/src/cli/plugin-commands.ts +70 -14
  42. package/src/cli/plugin-templates.ts +13 -13
  43. package/src/cli/registry.ts +209 -0
  44. package/src/cli/run.ts +109 -34
  45. package/src/cli/select.ts +143 -46
  46. package/src/cli/show.ts +59 -16
  47. package/src/cli/task-verb.ts +16 -6
  48. package/src/cli/upgrade.ts +151 -49
  49. package/src/cli/watch-cycle.ts +10 -5
  50. package/src/cli/watch-filter.ts +71 -3
  51. package/src/cli/watch-fs.ts +22 -6
  52. package/src/cli/watch-judge.ts +48 -6
  53. package/src/cli/watch.ts +163 -42
  54. package/src/cli/why.ts +211 -73
  55. package/src/cli/workspace-config.ts +54 -15
  56. package/src/config.ts +13 -1
  57. package/src/exec/env.ts +7 -2
  58. package/src/exec/executor.ts +154 -10
  59. package/src/exec/index.ts +6 -2
  60. package/src/exec/kill-tree.ts +15 -0
  61. package/src/exec/local-executor.ts +1 -1
  62. package/src/exec/proc-sample.ts +3 -1
  63. package/src/exec/runner.ts +253 -54
  64. package/src/exec/sandbox-binds.ts +7 -3
  65. package/src/exec/sandbox-paths.ts +1 -1
  66. package/src/exec/sandbox-runtime.ts +211 -24
  67. package/src/exec/sandbox-violations.ts +52 -20
  68. package/src/graph/critical-path.ts +79 -0
  69. package/src/graph/default-build.ts +19 -0
  70. package/src/graph/definition.ts +38 -0
  71. package/src/graph/folds.ts +38 -5
  72. package/src/graph/index.ts +4 -1
  73. package/src/graph/priorities.ts +74 -8
  74. package/src/graph/scheduler.ts +270 -60
  75. package/src/graph/task-graph.ts +228 -35
  76. package/src/handoff.ts +97 -9
  77. package/src/history/failure-mode.ts +6 -3
  78. package/src/history/history.ts +167 -61
  79. package/src/history/index.ts +2 -0
  80. package/src/history/metrics.ts +90 -30
  81. package/src/index.ts +30 -13
  82. package/src/orchestrator/admission.ts +10 -6
  83. package/src/orchestrator/affected-tasks.ts +11 -6
  84. package/src/orchestrator/deferred-outputs.ts +2 -0
  85. package/src/orchestrator/doctor.ts +134 -42
  86. package/src/orchestrator/execute-args.ts +4 -1
  87. package/src/orchestrator/execute-task.ts +130 -38
  88. package/src/orchestrator/fingerprint-watch.ts +1 -1
  89. package/src/orchestrator/graph-memo.ts +140 -0
  90. package/src/orchestrator/graph-stage.ts +18 -1
  91. package/src/orchestrator/held-servers.ts +1 -1
  92. package/src/orchestrator/hit-restore.ts +217 -119
  93. package/src/orchestrator/index.ts +4 -3
  94. package/src/orchestrator/local-floor.ts +119 -25
  95. package/src/orchestrator/local-shortcircuit.ts +28 -4
  96. package/src/orchestrator/lockfile-claim.ts +96 -16
  97. package/src/orchestrator/miss-save.ts +42 -8
  98. package/src/orchestrator/options.ts +8 -6
  99. package/src/orchestrator/output-host.ts +43 -14
  100. package/src/orchestrator/output-log.ts +90 -10
  101. package/src/orchestrator/persistent-task.ts +84 -29
  102. package/src/orchestrator/persistent.ts +16 -4
  103. package/src/orchestrator/placement.ts +185 -17
  104. package/src/orchestrator/plan.ts +64 -7
  105. package/src/orchestrator/plugin-host.ts +214 -42
  106. package/src/orchestrator/plugin.ts +113 -37
  107. package/src/orchestrator/prepare.ts +168 -30
  108. package/src/orchestrator/projects.ts +65 -40
  109. package/src/orchestrator/remote-prefetch.ts +119 -25
  110. package/src/orchestrator/run-artifacts.ts +95 -32
  111. package/src/orchestrator/run-context.ts +39 -41
  112. package/src/orchestrator/run-failures.ts +65 -23
  113. package/src/orchestrator/run-id.ts +6 -3
  114. package/src/orchestrator/run-keys.ts +149 -13
  115. package/src/orchestrator/run-live.ts +294 -0
  116. package/src/orchestrator/run-lock.ts +13 -7
  117. package/src/orchestrator/run-records.ts +35 -29
  118. package/src/orchestrator/run-report.ts +3 -3
  119. package/src/orchestrator/run-steps.ts +58 -22
  120. package/src/orchestrator/run-stop.ts +22 -5
  121. package/src/orchestrator/run.ts +331 -88
  122. package/src/orchestrator/sandbox-fix.ts +7 -5
  123. package/src/orchestrator/sandbox-request.ts +70 -20
  124. package/src/orchestrator/shell-verdict.ts +5 -5
  125. package/src/orchestrator/signals.ts +26 -1
  126. package/src/orchestrator/stable-keys.ts +92 -8
  127. package/src/orchestrator/steps.ts +2 -2
  128. package/src/orchestrator/task-artifacts.ts +90 -26
  129. package/src/orchestrator/task-hash.ts +78 -13
  130. package/src/orchestrator/task-log-buffer.ts +72 -36
  131. package/src/orchestrator/task-process.ts +7 -1
  132. package/src/orchestrator/telemetry-host.ts +22 -2
  133. package/src/orchestrator/telemetry.ts +53 -21
  134. package/src/orchestrator/upstream.ts +15 -0
  135. package/src/report/events.ts +32 -13
  136. package/src/report/forecast.ts +31 -9
  137. package/src/report/framed-output.ts +141 -50
  138. package/src/report/index.ts +1 -0
  139. package/src/report/logger.ts +170 -179
  140. package/src/report/path-links.ts +12 -4
  141. package/src/report/plain-output.ts +13 -5
  142. package/src/report/status-line.ts +81 -53
  143. package/src/report/summary.ts +114 -32
  144. package/src/report/terminal-signals.ts +13 -2
  145. package/src/settings.ts +7 -6
  146. package/src/util/bun-version.ts +4 -1
  147. package/src/util/colors.ts +4 -2
  148. package/src/util/config-frame.ts +41 -4
  149. package/src/{graph → util}/dependency-spec.ts +22 -13
  150. package/src/util/edit-distance.ts +13 -1
  151. package/src/util/env-flag.ts +9 -0
  152. package/src/util/errors.ts +59 -3
  153. package/src/util/essential-env.ts +57 -0
  154. package/src/util/git-ignored.ts +1 -1
  155. package/src/util/hash.ts +35 -0
  156. package/src/util/hide-env.ts +45 -5
  157. package/src/util/index.ts +21 -5
  158. package/src/util/num.ts +8 -0
  159. package/src/util/paths.ts +91 -21
  160. package/src/util/report.ts +6 -1
  161. package/src/util/secret-mask.ts +62 -1
  162. package/src/util/secrets.ts +7 -2
  163. package/src/util/size.ts +11 -3
  164. package/src/util/tail.ts +6 -1
  165. package/src/util/timing.ts +13 -1
  166. package/src/util/ui.ts +61 -3
  167. package/src/util/which.ts +1 -1
  168. package/src/util/write-file.ts +67 -0
  169. package/src/workspace/affected.ts +710 -107
  170. package/src/workspace/bracket-literal.ts +84 -0
  171. package/src/workspace/canonical-dir.ts +37 -0
  172. package/src/workspace/config-bytecode.ts +17 -11
  173. package/src/workspace/config-eval.ts +29 -8
  174. package/src/workspace/config-imports.ts +199 -58
  175. package/src/workspace/config-schema.ts +242 -39
  176. package/src/workspace/filter.ts +37 -8
  177. package/src/workspace/index.ts +11 -1
  178. package/src/workspace/json-data.ts +3 -4
  179. package/src/workspace/lockfile.ts +57 -8
  180. package/src/workspace/migration.ts +69 -18
  181. package/src/workspace/package-graph.ts +5 -4
  182. package/src/workspace/project-loader.ts +226 -37
  183. package/src/workspace/repo-id.ts +1 -1
  184. package/src/workspace/workspace.ts +176 -33
@@ -5,10 +5,11 @@
5
5
  // folds a runtime probe into its key; an `exec.interactive` task goes to the
6
6
  // local floor directly, as only it can hand over this terminal;
7
7
  // everything else asks the executors in declaration order, and the local
8
- // floor takes what nothing claimed.
8
+ // floor takes what nothing claimed. An `exclusive` executor lifts every
9
+ // pin and leaves the floor nothing: a task that would land here refuses.
9
10
 
10
11
  import { machineParallelism, UserError } from '../util/index.js'
11
- import { isLocalExecutor, selectExecutor, type TaskExecutor } from '../exec/index.js'
12
+ import { hostPlatform, isLocalExecutor, selectExecutor, type TaskExecutor } from '../exec/index.js'
12
13
  import { isGroupTask, type TaskNode } from '../graph/index.js'
13
14
  import { resolveDownloadModes } from './download-policy.js'
14
15
  import { type Logger, warnOf } from '../report/index.js'
@@ -34,13 +35,16 @@ export function pinnedLocalSet(
34
35
  servers = false,
35
36
  /** `false` for a `runtime` executor: it checks the probes where it runs the task. */
36
37
  runtime = true,
38
+ /** For an `exclusive` executor: it takes what pins elsewhere, servers included. */
39
+ exclusive = false,
37
40
  ): Set<string> {
38
41
  const pinned = new Set<string>()
39
42
  for (const node of nodes.values()) {
40
43
  if (
41
- (node.config.exec?.persistent !== undefined && !(servers && carriable(node))) ||
42
- (sandbox && node.config.exec?.sandbox !== undefined) ||
43
- node.config.exec?.remote === false
44
+ (node.config.exec?.persistent !== undefined && !(servers && carriable(node, exclusive))) ||
45
+ (!exclusive &&
46
+ ((sandbox && node.config.exec?.sandbox !== undefined) ||
47
+ node.config.exec?.remote === false))
44
48
  ) {
45
49
  pinned.add(node.id)
46
50
  }
@@ -71,17 +75,17 @@ export function pinnedLocalSet(
71
75
  /**
72
76
  * A persistent task a `servers` executor can start where it runs a
73
77
  * dependant: one the run holds for its dependants only, with no sandbox
74
- * (this machine's machinery) and no `exec.remote: false`.
78
+ * (this machine's machinery) and no `exec.remote: false`, which an
79
+ * `exclusive` executor takes all the same.
75
80
  */
76
- function carriable(node: TaskNode): boolean {
81
+ function carriable(node: TaskNode, exclusive = false): boolean {
77
82
  const exec = node.config.exec
78
83
  return (
79
84
  exec?.persistent !== undefined &&
80
85
  !node.requested &&
81
86
  node.surfaced !== true &&
82
- exec.sandbox === undefined &&
83
- exec.remote !== false &&
84
- exec.interactive !== true
87
+ (exclusive ||
88
+ (exec.sandbox === undefined && exec.remote !== false && exec.interactive !== true))
85
89
  )
86
90
  }
87
91
 
@@ -154,6 +158,17 @@ export function terminalHolders(nodes: Map<string, TaskNode>, tty: boolean): Set
154
158
  stack.push(...nodes.get(id)!.deps)
155
159
  }
156
160
  const clash = [...holders].filter((id) => id !== server && !before.has(id))
161
+ // One that runs after the server cannot run before it: the advice to make
162
+ // the server depend on it wrote a cycle.
163
+ const after = clash.filter((id) => reaches(nodes, id, server))
164
+ if (after.length > 0) {
165
+ const one = after.length === 1
166
+ throw new UserError(
167
+ `${server} is persistent and interactive, so it holds the terminal until the run ends, ` +
168
+ `and ${after.join(', ')}, which ${one ? 'depends' : 'depend'} on it, would ask for it too: ` +
169
+ `run ${one ? 'it' : 'them'} on ${one ? 'its' : 'their'} own`,
170
+ )
171
+ }
157
172
  if (clash.length > 0) {
158
173
  throw new UserError(
159
174
  `${server} is persistent and interactive, so it holds the terminal until the run ends, ` +
@@ -165,6 +180,20 @@ export function terminalHolders(nodes: Map<string, TaskNode>, tty: boolean): Set
165
180
  return holders
166
181
  }
167
182
 
183
+ /** Does `from` depend on `to`, directly or through others? */
184
+ function reaches(nodes: Map<string, TaskNode>, from: string, to: string): boolean {
185
+ const seen = new Set<string>()
186
+ const stack = [...nodes.get(from)!.deps]
187
+ while (stack.length > 0) {
188
+ const id = stack.pop()!
189
+ if (id === to) return true
190
+ if (seen.has(id)) continue
191
+ seen.add(id)
192
+ stack.push(...(nodes.get(id)?.deps ?? []))
193
+ }
194
+ return false
195
+ }
196
+
168
197
  export interface Placements {
169
198
  executors: Map<string, TaskExecutor>
170
199
  /**
@@ -181,6 +210,16 @@ export interface Placements {
181
210
  servers: Map<string, TaskNode[]>
182
211
  /** Persistent tasks every dependant of which carries them: never started here. */
183
212
  carried: Set<string>
213
+ /**
214
+ * Tasks whose `exec.platforms` leave out this machine, placed here: the
215
+ * run skips them and their dependants. Each → its platforms.
216
+ */
217
+ otherPlatform: Map<string, readonly string[]>
218
+ /**
219
+ * Of those, the ones an executor that routes by platform could not take:
220
+ * none of its `platforms` is theirs. Each → the platforms on offer.
221
+ */
222
+ unroutable: Map<string, readonly string[]>
184
223
  }
185
224
 
186
225
  /**
@@ -208,33 +247,88 @@ export function placeTasks(
208
247
  ): Placements {
209
248
  // Each executor's pins, computed only when one reads them.
210
249
  const pins = new Map<string, Set<string>>()
211
- const pinsOf = (e: Pick<TaskExecutor, 'sandbox' | 'servers' | 'runtime'>): Set<string> => {
212
- const k = `${e.sandbox === true}${e.servers === true}${e.runtime === true}`
250
+ const pinsOf = (
251
+ e: Pick<TaskExecutor, 'sandbox' | 'servers' | 'runtime' | 'exclusive'>,
252
+ ): Set<string> => {
253
+ const k = `${e.sandbox === true}${e.servers === true}${e.runtime === true}${e.exclusive === true}`
213
254
  let set = pins.get(k)
214
255
  if (set === undefined) {
215
- set = pinnedLocalSet(nodes, e.sandbox !== true, e.servers === true, e.runtime !== true)
256
+ set = pinnedLocalSet(
257
+ nodes,
258
+ e.sandbox !== true,
259
+ e.servers === true,
260
+ e.runtime !== true,
261
+ e.exclusive === true,
262
+ )
216
263
  pins.set(k, set)
217
264
  }
218
265
  return set
219
266
  }
220
- const pinned = pinsOf({})
221
267
  const placements: Placements = {
222
268
  executors: new Map(),
223
269
  remoteOnlyNoop: new Set(),
224
270
  remoteOnly: new Set(),
225
271
  servers: new Map(),
226
272
  carried: new Set(),
273
+ otherPlatform: new Map(),
274
+ unroutable: new Map(),
275
+ }
276
+ const host = hostPlatform()
277
+ /** Runs `node` here: on this machine's platform, or skipped if it is not one of its. */
278
+ const here = (node: TaskNode): void => {
279
+ const wants: readonly string[] | undefined = node.config.exec?.platforms
280
+ if (wants === undefined) return
281
+ if (wants.includes(host)) node.platform = host
282
+ else placements.otherPlatform.set(node.id, wants)
227
283
  }
228
284
  /** Persistent tasks a dependant placed off a `servers` executor needs here. */
229
285
  const heldHere = new Set<string>()
230
286
  // `resolveExecutors` puts it last in every list.
231
287
  const floor = executors.find(isLocalExecutor)!
288
+ // The floor alone takes every task and starts no server: nothing to pin or
289
+ // select. The walks below were 2.8 ms of a 1,200-task warm run.
290
+ if (executors.length === 1 && executors[0] === floor) {
291
+ for (const node of nodes.values()) {
292
+ delete node.platform
293
+ if (isGroupTask(node)) continue
294
+ here(node)
295
+ if (node.config.exec?.persistent !== undefined) continue
296
+ placements.executors.set(node.id, floor)
297
+ if (node.config.exec?.remote === 'only') placements.remoteOnlyNoop.add(node.id)
298
+ }
299
+ return placements
300
+ }
301
+ const pinned = pinsOf({})
302
+ const routers = executors.filter((e) => e.platforms !== undefined)
303
+ const sole = executors.find((e) => e.exclusive === true)
304
+ /** A task an `exclusive` executor leaves to this machine: the run refuses. */
305
+ const refuse = (id: string, why: string): never => {
306
+ throw new UserError(
307
+ `${id} would run on this machine, and ${executorLabel(sole!)} runs every task elsewhere: ${why}`,
308
+ )
309
+ }
310
+ // A server it cannot carry first: one asked for pins its dependants, and
311
+ // the run named a dependant, as folding a runtime probe (2026-10-24).
312
+ if (sole !== undefined)
313
+ for (const node of nodes.values())
314
+ if (
315
+ node.config.exec?.persistent !== undefined &&
316
+ !(sole.servers === true && carriable(node, true))
317
+ )
318
+ refuse(node.id, 'it starts a server only beside a task that needs it')
232
319
  for (const node of nodes.values()) {
320
+ delete node.platform
233
321
  if (isGroupTask(node) || node.config.exec?.persistent !== undefined) continue
234
- if (node.config.exec?.interactive === true) {
322
+ if (node.config.exec?.interactive === true && sole === undefined) {
235
323
  placements.executors.set(node.id, floor)
324
+ here(node)
325
+ // Its servers run here with it: carried, none started under it.
326
+ for (const s of serversBehind(nodes, node)) heldHere.add(s.id)
236
327
  continue
237
328
  }
329
+ const wants: readonly string[] | undefined = node.config.exec?.platforms
330
+ const routes = (e: TaskExecutor): boolean =>
331
+ wants === undefined || e.platforms?.some((p) => wants.includes(p)) === true
238
332
  const executor = selectExecutor(
239
333
  executors,
240
334
  {
@@ -246,9 +340,33 @@ export function placeTasks(
246
340
  cacheable: node.config.cache !== undefined,
247
341
  },
248
342
  executorLabel,
249
- (e) => pinsOf(e).has(node.id),
343
+ (e) => pinsOf(e).has(node.id) || !routes(e),
250
344
  )
345
+ if (sole !== undefined && executor === floor) {
346
+ refuse(
347
+ node.id,
348
+ pinsOf(sole).has(node.id)
349
+ ? 'its key includes a runtime probe, which it does not check'
350
+ : routes(sole)
351
+ ? 'it declined the task'
352
+ : `it offers ${sole.platforms?.join(', ') ?? 'no platform'}, and exec.platforms asks for ${wants!.join(', ')}`,
353
+ )
354
+ }
251
355
  placements.executors.set(node.id, executor)
356
+ if (wants !== undefined) {
357
+ const offered = executor.platforms
358
+ if (offered !== undefined && routes(executor)) {
359
+ node.platform =
360
+ wants.includes(host) && offered.includes(host)
361
+ ? host
362
+ : wants.find((p) => offered.includes(p))!
363
+ } else {
364
+ here(node)
365
+ if (placements.otherPlatform.has(node.id) && routers.length > 0) {
366
+ placements.unroutable.set(node.id, [...new Set(routers.flatMap((e) => e.platforms!))])
367
+ }
368
+ }
369
+ }
252
370
  const servers = serversBehind(nodes, node)
253
371
  if (servers.length > 0) {
254
372
  if (executor.servers === true) placements.servers.set(node.id, servers)
@@ -263,6 +381,11 @@ export function placeTasks(
263
381
  }
264
382
  for (const servers of placements.servers.values())
265
383
  for (const s of servers) if (!heldHere.has(s.id)) placements.carried.add(s.id)
384
+ for (const node of nodes.values()) {
385
+ if (node.config.exec?.persistent === undefined || placements.carried.has(node.id)) continue
386
+ if (sole !== undefined) refuse(node.id, 'it starts a server only beside a task that needs it')
387
+ here(node)
388
+ }
266
389
  return placements
267
390
  }
268
391
 
@@ -275,16 +398,20 @@ export function placeTasks(
275
398
  * inspection command and must not fail over a label. The error is still
276
399
  * said, on the status line, in the plugin's name: the run this plan
277
400
  * previews would refuse on it, and a plan that hid that would read as
278
- * "everything lands locally".
401
+ * "everything lands locally". The tasks `exec.platforms` skip here ride
402
+ * along, and a platform refusal (CI, an executor that cannot route) throws
403
+ * as the run's does.
279
404
  */
280
405
  export async function planExecutorOf(
281
406
  prepared: Awaited<ReturnType<typeof prepareRun>>,
282
407
  log: Logger,
283
408
  policy: 'all' | 'toplevel' | 'none',
409
+ ci: boolean,
284
410
  ): Promise<{
285
411
  executorOf?: (id: string) => string | undefined
286
412
  downloadOf?: (id: string) => 'eager' | 'deferred' | 'never' | undefined
287
413
  downloadDowngrades?: ReadonlyArray<{ taskId: string; reason: string }>
414
+ otherPlatform?: ReadonlyMap<string, readonly string[]>
288
415
  }> {
289
416
  let executors: readonly TaskExecutor[]
290
417
  try {
@@ -303,6 +430,9 @@ export async function planExecutorOf(
303
430
  return {}
304
431
  }
305
432
  const placements = placeTasks(prepared.nodes, executors)
433
+ // What the run refuses on, the plan refuses on: on CI a skip fails the run.
434
+ const platforms = platformVerdict(placements, ci)
435
+ if (platforms !== undefined && 'refuse' in platforms) throw new UserError(platforms.refuse)
306
436
  // Download modes need placement regardless of how many executors there
307
437
  // are (a single REMOTE one still defers); executor LABELS only earn their
308
438
  // column when there is a choice to report.
@@ -316,6 +446,7 @@ export async function planExecutorOf(
316
446
  remoteOnly: placements.remoteOnly,
317
447
  })
318
448
  return {
449
+ ...(placements.otherPlatform.size > 0 ? { otherPlatform: placements.otherPlatform } : {}),
319
450
  ...(executors.length < 2 && placements.remoteOnlyNoop.size === 0
320
451
  ? {}
321
452
  : {
@@ -380,3 +511,40 @@ export function poolOfPlacement(
380
511
  return executor === undefined ? undefined : pools.get(executor)
381
512
  }
382
513
  }
514
+
515
+ /**
516
+ * What a run says of its `otherPlatform` tasks: a refusal when an executor
517
+ * that routes by platform could not take one, or the run is on CI (each
518
+ * task runs there or the run fails, 2026-10-23), else the warning a
519
+ * local run prints as it skips them and what depends on them.
520
+ */
521
+ export function platformVerdict(
522
+ placements: Placements,
523
+ ci: boolean,
524
+ ): { refuse: string } | { warn: string } | undefined {
525
+ const skipped = placements.otherPlatform
526
+ if (skipped.size === 0) return undefined
527
+ const host = hostPlatform()
528
+ const needs = (id: string): string => `${id} needs ${skipped.get(id)!.join(' or ')}`
529
+ if (placements.unroutable.size > 0) {
530
+ const offered = [...new Set([...placements.unroutable.values()].flat())]
531
+ return {
532
+ refuse:
533
+ `no executor runs ${placements.unroutable.size === 1 ? 'this task' : 'these tasks'} on a platform ${placements.unroutable.size === 1 ? 'it lists' : 'they list'} ` +
534
+ `(this machine is ${host}; executors run ${offered.join(', ')}):\n` +
535
+ [...placements.unroutable.keys()].map((id) => ` ${needs(id)}`).join('\n') +
536
+ `\ngive the executor a worker of each platform listed`,
537
+ }
538
+ }
539
+ if (ci) {
540
+ return {
541
+ refuse:
542
+ `on CI every task runs or the run fails, and ${skipped.size === 1 ? 'this task' : 'these tasks'} cannot run on ${host}:\n` +
543
+ [...skipped.keys()].map((id) => ` ${needs(id)}`).join('\n') +
544
+ `\nrun a target that holds only ${host} tasks, or one DTE run with a worker of each platform`,
545
+ }
546
+ }
547
+ return {
548
+ warn: `[vx] skipped on ${host}, with what depends on them: ${[...skipped.keys()].map(needs).join('; ')}`,
549
+ }
550
+ }
@@ -17,8 +17,9 @@ import { ranNoCache } from '../report/index.js'
17
17
  import { idleServers, isGroupTask, runGraph, type TaskNode } from '../graph/index.js'
18
18
  import type { AffectedReason } from './affected-tasks.js'
19
19
  import type { HistoryProvider } from '../history/index.js'
20
+ import { hostPlatform } from '../exec/index.js'
20
21
  import { computeGroupKey, computeTaskHash } from './task-hash.js'
21
- import { keyedOutcome, keyUpstream } from './upstream.js'
22
+ import { dependedOn, keyedOutcome, keylessServer, keyUpstream } from './upstream.js'
22
23
 
23
24
  export type CacheStatus =
24
25
  | 'hit-local' // entry exists in local cache
@@ -27,6 +28,7 @@ export type CacheStatus =
27
28
  | 'no-cache' // task opts out of caching (no `cache` block) or --no-cache
28
29
  | 'group' // no `exec`; just an aggregator
29
30
  | 'not-started' // a server every dependant of which restores early: the run never spawns it
31
+ | 'skipped' // `exec.platforms` leave this machine out, or a task it depends on is skipped
30
32
 
31
33
  export interface PlannedTask {
32
34
  node: TaskNode
@@ -58,6 +60,10 @@ export interface PlannedTask {
58
60
  remoteBytes?: number
59
61
  /** Why `--affected` kept this requested task; absent without `--affected` and on a dependency it pulled in. */
60
62
  affected?: AffectedReason
63
+ /** A `skipped` task's own platforms, and this machine's. */
64
+ otherPlatform?: { readonly needs: readonly string[]; readonly host: string }
65
+ /** A `skipped` task's root: the task off this machine's platform it depends on. */
66
+ blockedBy?: string
61
67
  }
62
68
 
63
69
  /**
@@ -121,10 +127,13 @@ export interface PlanArgs {
121
127
  executorOf?: (id: string) => string | undefined
122
128
  /**
123
129
  * The tasks the run's short-circuit would restore ahead of their deps on
124
- * a local hit (stable key, no workspace-output reach). A server whose
125
- * every dependant is one of those and a local hit is never spawned.
130
+ * a hit (stable key, no workspace-output reach). A server whose every
131
+ * dependant is one of those and a hit, local or remote, is never spawned:
132
+ * the run holds it for the remote's answer (`held-servers.ts`).
126
133
  */
127
134
  restorable?: ReadonlySet<string>
135
+ /** Tasks whose `exec.platforms` leave this machine out (`placement.ts`), each → its platforms. */
136
+ otherPlatform?: ReadonlyMap<string, readonly string[]>
128
137
  }
129
138
 
130
139
  /**
@@ -145,6 +154,7 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
145
154
  // Keyed first, probed after: a remote that can batch (`hasMany`) is
146
155
  // asked once for every key, one that cannot is asked per key.
147
156
  const probed = new Map<string, string>()
157
+ const depended = dependedOn(args.nodes.values())
148
158
 
149
159
  const outcomes = await runGraph({
150
160
  nodes: args.nodes,
@@ -170,7 +180,7 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
170
180
  return keyedOutcome(node, await computeGroupKey(hashArgs))
171
181
  }
172
182
 
173
- const hash = await computeTaskHash(hashArgs)
183
+ const hash = keylessServer(node, depended) ? undefined : await computeTaskHash(hashArgs)
174
184
 
175
185
  // Prediction keys off READS: a no-read policy re-executes every task,
176
186
  // so nothing is probed. Whether that run is a miss or no cache at all
@@ -181,7 +191,7 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
181
191
  const policy = args.cachePolicy ?? FULL_CACHE_POLICY
182
192
  const reads = policy.localRead || policy.remoteRead
183
193
  let status: CacheStatus
184
- if (ranNoCache({ node, cacheOff: cachesNothing(policy) })) {
194
+ if (hash === undefined || ranNoCache({ node, cacheOff: cachesNothing(policy) })) {
185
195
  status = 'no-cache'
186
196
  } else if (!reads) {
187
197
  status = 'miss'
@@ -208,7 +218,10 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
208
218
 
209
219
  if (args.restorable !== undefined) {
210
220
  const restoreTier = new Set(
211
- [...args.restorable].filter((id) => cacheStatusById.get(id) === 'hit-local'),
221
+ [...args.restorable].filter((id) => {
222
+ const status = cacheStatusById.get(id)
223
+ return status === 'hit-local' || status === 'hit-remote'
224
+ }),
212
225
  )
213
226
  const dependents = new Map<string, string[]>()
214
227
  for (const node of args.nodes.values())
@@ -217,6 +230,7 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
217
230
  cacheStatusById.set(id, 'not-started')
218
231
  }
219
232
 
233
+ const skips = platformSkips(args.nodes, args.otherPlatform)
220
234
  const tasks: PlannedTask[] = []
221
235
  for (const id of args.nodes.keys()) {
222
236
  const o = outcomes.get(id)
@@ -226,11 +240,12 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
226
240
  tasks.push({
227
241
  node,
228
242
  hash: o.hash ?? '',
229
- cacheStatus: cacheStatusById.get(id) ?? 'no-cache',
243
+ cacheStatus: skips.has(id) ? 'skipped' : (cacheStatusById.get(id) ?? 'no-cache'),
230
244
  deps: node.deps,
231
245
  ...(executor !== undefined ? { executor } : {}),
232
246
  ...(args.downloadOf?.(id) === 'deferred' ? { download: 'deferred' as const } : {}),
233
247
  ...(remoteBytesById.has(id) ? { remoteBytes: remoteBytesById.get(id)! } : {}),
248
+ ...skips.get(id),
234
249
  })
235
250
  }
236
251
 
@@ -258,6 +273,48 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
258
273
  return { tasks, ...downgradeField(args) }
259
274
  }
260
275
 
276
+ /**
277
+ * What the run skips here, as its scheduler does: a task its `exec.platforms`
278
+ * leave this machine out of, and every task that depends on one, named by
279
+ * the root of the block. In every `--continue` mode: a skip is no failure.
280
+ */
281
+ function platformSkips(
282
+ nodes: ReadonlyMap<string, TaskNode>,
283
+ otherPlatform: ReadonlyMap<string, readonly string[]> | undefined,
284
+ ): Map<string, Pick<PlannedTask, 'otherPlatform' | 'blockedBy'>> {
285
+ const skips = new Map<string, Pick<PlannedTask, 'otherPlatform' | 'blockedBy'>>()
286
+ if (otherPlatform === undefined || otherPlatform.size === 0) return skips
287
+ const host = hostPlatform()
288
+ const settled = new Set<string>()
289
+ const visit = (id: string): void => {
290
+ const stack = [id]
291
+ while (stack.length > 0) {
292
+ const top = stack[stack.length - 1]!
293
+ if (settled.has(top)) {
294
+ stack.pop()
295
+ continue
296
+ }
297
+ const node = nodes.get(top)!
298
+ const open = node.deps.filter((d) => !settled.has(d))
299
+ if (open.length > 0) {
300
+ stack.push(...open)
301
+ continue
302
+ }
303
+ stack.pop()
304
+ settled.add(top)
305
+ const needs = otherPlatform.get(top)
306
+ if (needs !== undefined) {
307
+ skips.set(top, { otherPlatform: { needs, host } })
308
+ continue
309
+ }
310
+ const via = node.deps.find((d) => skips.has(d))
311
+ if (via !== undefined) skips.set(top, { blockedBy: skips.get(via)!.blockedBy ?? via })
312
+ }
313
+ }
314
+ for (const id of nodes.keys()) visit(id)
315
+ return skips
316
+ }
317
+
261
318
  /** Carried onto the plan only when the gate actually refused something. */
262
319
  function downgradeField(args: PlanArgs): {
263
320
  downloadDowngrades?: ReadonlyArray<{ taskId: string; reason: string }>