@vzn/vx 0.0.645 → 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 (186) hide show
  1. package/README.md +39 -50
  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 +127 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +19 -3
  12. package/src/bin.ts +54 -13
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +330 -65
  15. package/src/cache/git-inputs.ts +274 -97
  16. package/src/cache/index.ts +7 -2
  17. package/src/cache/inputs.ts +515 -83
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +74 -11
  20. package/src/cache/layered-cache.ts +154 -59
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +44 -14
  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 +100 -15
  26. package/src/cache/run-history.ts +77 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +42 -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/core-alias.ts +5 -2
  34. package/src/cli/docs.ts +42 -16
  35. package/src/cli/help.ts +62 -25
  36. package/src/cli/index.ts +79 -45
  37. package/src/cli/info.ts +12 -6
  38. package/src/cli/init.ts +222 -34
  39. package/src/cli/last.ts +443 -47
  40. package/src/cli/lock.ts +197 -52
  41. package/src/cli/plan-format.ts +91 -49
  42. package/src/cli/plugin-commands.ts +160 -11
  43. package/src/cli/plugin-templates.ts +13 -13
  44. package/src/cli/registry.ts +209 -0
  45. package/src/cli/run.ts +111 -41
  46. package/src/cli/select.ts +148 -49
  47. package/src/cli/show.ts +59 -16
  48. package/src/cli/task-verb.ts +16 -6
  49. package/src/cli/upgrade.ts +155 -54
  50. package/src/cli/watch-cycle.ts +10 -5
  51. package/src/cli/watch-filter.ts +71 -3
  52. package/src/cli/watch-fs.ts +22 -6
  53. package/src/cli/watch-judge.ts +48 -6
  54. package/src/cli/watch.ts +163 -42
  55. package/src/cli/why.ts +211 -73
  56. package/src/cli/workspace-config.ts +54 -15
  57. package/src/config.ts +13 -1
  58. package/src/exec/env.ts +7 -2
  59. package/src/exec/executor.ts +262 -15
  60. package/src/exec/index.ts +8 -2
  61. package/src/exec/kill-tree.ts +15 -0
  62. package/src/exec/local-executor.ts +1 -1
  63. package/src/exec/proc-sample.ts +70 -6
  64. package/src/exec/runner.ts +256 -53
  65. package/src/exec/sandbox-binds.ts +7 -3
  66. package/src/exec/sandbox-paths.ts +1 -1
  67. package/src/exec/sandbox-runtime.ts +222 -28
  68. package/src/exec/sandbox-violations.ts +52 -20
  69. package/src/graph/critical-path.ts +79 -0
  70. package/src/graph/default-build.ts +19 -0
  71. package/src/graph/definition.ts +38 -0
  72. package/src/graph/folds.ts +38 -5
  73. package/src/graph/index.ts +4 -1
  74. package/src/graph/priorities.ts +74 -8
  75. package/src/graph/scheduler.ts +326 -65
  76. package/src/graph/task-graph.ts +228 -35
  77. package/src/handoff.ts +97 -9
  78. package/src/history/failure-mode.ts +6 -3
  79. package/src/history/history.ts +268 -50
  80. package/src/history/index.ts +2 -0
  81. package/src/history/metrics.ts +142 -45
  82. package/src/index.ts +42 -10
  83. package/src/orchestrator/admission.ts +10 -6
  84. package/src/orchestrator/affected-tasks.ts +11 -6
  85. package/src/orchestrator/deferred-outputs.ts +2 -0
  86. package/src/orchestrator/doctor.ts +134 -42
  87. package/src/orchestrator/execute-args.ts +6 -0
  88. package/src/orchestrator/execute-task.ts +155 -42
  89. package/src/orchestrator/fingerprint-watch.ts +1 -1
  90. package/src/orchestrator/graph-memo.ts +140 -0
  91. package/src/orchestrator/graph-stage.ts +18 -1
  92. package/src/orchestrator/held-servers.ts +86 -0
  93. package/src/orchestrator/hit-restore.ts +227 -122
  94. package/src/orchestrator/index.ts +20 -3
  95. package/src/orchestrator/local-floor.ts +198 -0
  96. package/src/orchestrator/local-shortcircuit.ts +51 -8
  97. package/src/orchestrator/lockfile-claim.ts +96 -16
  98. package/src/orchestrator/miss-save.ts +52 -12
  99. package/src/orchestrator/options.ts +8 -6
  100. package/src/orchestrator/output-host.ts +43 -14
  101. package/src/orchestrator/output-log.ts +90 -10
  102. package/src/orchestrator/persistent-task.ts +97 -28
  103. package/src/orchestrator/persistent.ts +16 -4
  104. package/src/orchestrator/placement.ts +257 -14
  105. package/src/orchestrator/plan.ts +64 -7
  106. package/src/orchestrator/plugin-host.ts +230 -40
  107. package/src/orchestrator/plugin.ts +150 -37
  108. package/src/orchestrator/prepare.ts +178 -32
  109. package/src/orchestrator/projects.ts +65 -40
  110. package/src/orchestrator/remote-prefetch.ts +119 -25
  111. package/src/orchestrator/run-artifacts.ts +95 -32
  112. package/src/orchestrator/run-context.ts +39 -41
  113. package/src/orchestrator/run-failures.ts +65 -23
  114. package/src/orchestrator/run-id.ts +6 -3
  115. package/src/orchestrator/run-keys.ts +300 -0
  116. package/src/orchestrator/run-live.ts +294 -0
  117. package/src/orchestrator/run-lock.ts +13 -7
  118. package/src/orchestrator/run-records.ts +35 -23
  119. package/src/orchestrator/run-report.ts +3 -3
  120. package/src/orchestrator/run-steps.ts +58 -22
  121. package/src/orchestrator/run-stop.ts +22 -5
  122. package/src/orchestrator/run.ts +409 -79
  123. package/src/orchestrator/sandbox-fix.ts +7 -5
  124. package/src/orchestrator/sandbox-request.ts +78 -22
  125. package/src/orchestrator/shell-verdict.ts +5 -5
  126. package/src/orchestrator/signals.ts +26 -1
  127. package/src/orchestrator/stable-keys.ts +92 -8
  128. package/src/orchestrator/steps.ts +2 -2
  129. package/src/orchestrator/task-artifacts.ts +206 -0
  130. package/src/orchestrator/task-hash.ts +89 -14
  131. package/src/orchestrator/task-log-buffer.ts +72 -36
  132. package/src/orchestrator/task-process.ts +22 -0
  133. package/src/orchestrator/telemetry-host.ts +24 -3
  134. package/src/orchestrator/telemetry.ts +113 -38
  135. package/src/orchestrator/upstream.ts +15 -0
  136. package/src/report/events.ts +32 -13
  137. package/src/report/forecast.ts +36 -35
  138. package/src/report/framed-output.ts +173 -46
  139. package/src/report/index.ts +1 -0
  140. package/src/report/logger.ts +217 -186
  141. package/src/report/path-links.ts +12 -4
  142. package/src/report/plain-output.ts +13 -5
  143. package/src/report/status-line.ts +94 -46
  144. package/src/report/summary.ts +174 -38
  145. package/src/report/terminal-signals.ts +13 -2
  146. package/src/settings.ts +14 -6
  147. package/src/util/bun-version.ts +4 -1
  148. package/src/util/colors.ts +20 -3
  149. package/src/util/config-frame.ts +41 -4
  150. package/src/{graph → util}/dependency-spec.ts +22 -13
  151. package/src/util/edit-distance.ts +13 -1
  152. package/src/util/env-flag.ts +9 -0
  153. package/src/util/errors.ts +59 -3
  154. package/src/util/essential-env.ts +57 -0
  155. package/src/util/git-ignored.ts +1 -1
  156. package/src/util/hash.ts +35 -0
  157. package/src/util/hide-env.ts +45 -5
  158. package/src/util/index.ts +22 -5
  159. package/src/util/num.ts +8 -0
  160. package/src/util/paths.ts +121 -21
  161. package/src/util/report.ts +6 -1
  162. package/src/util/secret-mask.ts +62 -1
  163. package/src/util/secrets.ts +7 -2
  164. package/src/util/size.ts +11 -3
  165. package/src/util/tail.ts +6 -1
  166. package/src/util/timing.ts +13 -1
  167. package/src/util/ui.ts +65 -4
  168. package/src/util/which.ts +1 -1
  169. package/src/util/write-file.ts +67 -0
  170. package/src/workspace/affected.ts +710 -107
  171. package/src/workspace/bracket-literal.ts +84 -0
  172. package/src/workspace/canonical-dir.ts +37 -0
  173. package/src/workspace/config-bytecode.ts +282 -0
  174. package/src/workspace/config-eval.ts +29 -8
  175. package/src/workspace/config-imports.ts +199 -58
  176. package/src/workspace/config-schema.ts +242 -39
  177. package/src/workspace/filter.ts +37 -8
  178. package/src/workspace/index.ts +11 -2
  179. package/src/workspace/json-data.ts +3 -4
  180. package/src/workspace/lockfile.ts +57 -8
  181. package/src/workspace/migration.ts +72 -49
  182. package/src/workspace/package-graph.ts +5 -4
  183. package/src/workspace/project-loader.ts +314 -47
  184. package/src/workspace/repo-id.ts +1 -1
  185. package/src/workspace/workspace.ts +176 -33
  186. package/src/cli/foreign-flags.ts +0 -727
@@ -1,19 +1,21 @@
1
1
  // Where each task runs: the placement of a graph over the resolved
2
2
  // executors, and the plan-mode view of it. Split from run.ts on 2026-09-10
3
3
  // (pure motion). A task is pinned to this machine when it is persistent,
4
- // depends on a persistent task, is sandboxed, says `exec.remote: false`, or
4
+ // depends on a persistent task no `servers` executor carries, is sandboxed, says `exec.remote: false`, or
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'
15
16
  import { executorLabel, resolveExecutors } from './plugin-host.js'
16
17
  import type { prepareRun } from './prepare.js'
18
+ import { cacheFacts, taskArtifacts } from './task-artifacts.js'
17
19
 
18
20
  /**
19
21
  * A task is pinned to this machine when it is persistent, transitively
@@ -22,20 +24,32 @@ import type { prepareRun } from './prepare.js'
22
24
  * machinery — a worker has none of it, and a boundary "verified" where it
23
25
  * is not enforced passes vacuously), or declares `exec.remote: false`. A
24
26
  * dependant of a pinned task is pinned with it. A task whose key folds a
25
- * runtime probe is pinned alone (`withProbedRuntime`).
27
+ * runtime probe is pinned alone (`withProbedRuntime`). `sandbox: false`
28
+ * leaves `exec.sandbox` out: the set for an executor that enforces it where
29
+ * it runs (`TaskExecutor.sandbox`).
26
30
  */
27
- export function pinnedLocalSet(nodes: Map<string, TaskNode>): Set<string> {
31
+ export function pinnedLocalSet(
32
+ nodes: Map<string, TaskNode>,
33
+ sandbox = true,
34
+ /** For a `servers` executor: a carriable persistent task pins nothing. */
35
+ servers = false,
36
+ /** `false` for a `runtime` executor: it checks the probes where it runs the task. */
37
+ runtime = true,
38
+ /** For an `exclusive` executor: it takes what pins elsewhere, servers included. */
39
+ exclusive = false,
40
+ ): Set<string> {
28
41
  const pinned = new Set<string>()
29
42
  for (const node of nodes.values()) {
30
43
  if (
31
- node.config.exec?.persistent !== undefined ||
32
- node.config.exec?.sandbox !== undefined ||
33
- 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))
34
48
  ) {
35
49
  pinned.add(node.id)
36
50
  }
37
51
  }
38
- if (pinned.size === 0) return withProbedRuntime(nodes, pinned)
52
+ if (pinned.size === 0) return runtime ? withProbedRuntime(nodes, pinned) : pinned
39
53
  // Pinning flows up the dependant edges from what pins itself, one walk on
40
54
  // an explicit stack: a chain is as deep as the graph, and a recursion
41
55
  // per edge threw `RangeError` on the 50,000 the builder takes (item 737).
@@ -55,14 +69,50 @@ export function pinnedLocalSet(nodes: Map<string, TaskNode>): Set<string> {
55
69
  stack.push(up)
56
70
  }
57
71
  }
58
- return withProbedRuntime(nodes, pinned)
72
+ return runtime ? withProbedRuntime(nodes, pinned) : pinned
73
+ }
74
+
75
+ /**
76
+ * A persistent task a `servers` executor can start where it runs a
77
+ * dependant: one the run holds for its dependants only, with no sandbox
78
+ * (this machine's machinery) and no `exec.remote: false`, which an
79
+ * `exclusive` executor takes all the same.
80
+ */
81
+ function carriable(node: TaskNode, exclusive = false): boolean {
82
+ const exec = node.config.exec
83
+ return (
84
+ exec?.persistent !== undefined &&
85
+ !node.requested &&
86
+ node.surfaced !== true &&
87
+ (exclusive ||
88
+ (exec.sandbox === undefined && exec.remote !== false && exec.interactive !== true))
89
+ )
90
+ }
91
+
92
+ /** The persistent tasks `node` depends on through groups and servers, each after its own. */
93
+ function serversBehind(nodes: ReadonlyMap<string, TaskNode>, node: TaskNode): TaskNode[] {
94
+ const out: TaskNode[] = []
95
+ const seen = new Set<string>()
96
+ const visit = (id: string): void => {
97
+ if (seen.has(id)) return
98
+ seen.add(id)
99
+ const dep = nodes.get(id)
100
+ if (dep === undefined) return
101
+ const server = dep.config.exec?.persistent !== undefined
102
+ if (!server && !isGroupTask(dep)) return
103
+ for (const d of dep.deps) visit(d)
104
+ if (server) out.push(dep)
105
+ }
106
+ for (const d of node.deps) visit(d)
107
+ return out
59
108
  }
60
109
 
61
110
  /**
62
111
  * A key that folds `inputs.runtime` / `workspaceRuntime` holds what
63
112
  * THIS machine answered (`node -v`); a worker runs its own runtime, which no
64
113
  * executor can prove equal, and its output saved under this key is a stale
65
- * hit here (C-2). Such a task runs here. Its dependants are not pinned with
114
+ * hit here (C-2). Such a task runs here, or on a `runtime` executor, which
115
+ * fails it where its probes answer otherwise. Its dependants are not pinned with
66
116
  * it: they fold the probe through its input key, and their own output does
67
117
  * not depend on this machine's runtime.
68
118
  */
@@ -108,6 +158,17 @@ export function terminalHolders(nodes: Map<string, TaskNode>, tty: boolean): Set
108
158
  stack.push(...nodes.get(id)!.deps)
109
159
  }
110
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
+ }
111
172
  if (clash.length > 0) {
112
173
  throw new UserError(
113
174
  `${server} is persistent and interactive, so it holds the terminal until the run ends, ` +
@@ -119,6 +180,20 @@ export function terminalHolders(nodes: Map<string, TaskNode>, tty: boolean): Set
119
180
  return holders
120
181
  }
121
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
+
122
197
  export interface Placements {
123
198
  executors: Map<string, TaskExecutor>
124
199
  /**
@@ -131,6 +206,20 @@ export interface Placements {
131
206
  remoteOnlyNoop: Set<string>
132
207
  /** `'only'` tasks a remote executor DID take — executed remotely, outputs stay remote. */
133
208
  remoteOnly: Set<string>
209
+ /** Each task placed on a `servers` executor → the persistent tasks it starts there. */
210
+ servers: Map<string, TaskNode[]>
211
+ /** Persistent tasks every dependant of which carries them: never started here. */
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[]>
134
223
  }
135
224
 
136
225
  /**
@@ -156,20 +245,90 @@ export function placeTasks(
156
245
  nodes: Map<string, TaskNode>,
157
246
  executors: readonly TaskExecutor[],
158
247
  ): Placements {
159
- const pinned = pinnedLocalSet(nodes)
248
+ // Each executor's pins, computed only when one reads them.
249
+ const pins = new Map<string, Set<string>>()
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}`
254
+ let set = pins.get(k)
255
+ if (set === undefined) {
256
+ set = pinnedLocalSet(
257
+ nodes,
258
+ e.sandbox !== true,
259
+ e.servers === true,
260
+ e.runtime !== true,
261
+ e.exclusive === true,
262
+ )
263
+ pins.set(k, set)
264
+ }
265
+ return set
266
+ }
160
267
  const placements: Placements = {
161
268
  executors: new Map(),
162
269
  remoteOnlyNoop: new Set(),
163
270
  remoteOnly: new Set(),
271
+ servers: new Map(),
272
+ carried: new Set(),
273
+ otherPlatform: new Map(),
274
+ unroutable: new Map(),
164
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)
283
+ }
284
+ /** Persistent tasks a dependant placed off a `servers` executor needs here. */
285
+ const heldHere = new Set<string>()
165
286
  // `resolveExecutors` puts it last in every list.
166
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')
167
319
  for (const node of nodes.values()) {
320
+ delete node.platform
168
321
  if (isGroupTask(node) || node.config.exec?.persistent !== undefined) continue
169
- if (node.config.exec?.interactive === true) {
322
+ if (node.config.exec?.interactive === true && sole === undefined) {
170
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)
171
327
  continue
172
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
173
332
  const executor = selectExecutor(
174
333
  executors,
175
334
  {
@@ -181,8 +340,38 @@ export function placeTasks(
181
340
  cacheable: node.config.cache !== undefined,
182
341
  },
183
342
  executorLabel,
343
+ (e) => pinsOf(e).has(node.id) || !routes(e),
184
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
+ }
185
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
+ }
370
+ const servers = serversBehind(nodes, node)
371
+ if (servers.length > 0) {
372
+ if (executor.servers === true) placements.servers.set(node.id, servers)
373
+ else for (const s of servers) heldHere.add(s.id)
374
+ }
186
375
  if (node.config.exec?.remote === 'only') {
187
376
  // A pinned 'only' task (it transitively depends on a persistent one)
188
377
  // lands here too: pinning wins, so it noops rather than shipping.
@@ -190,6 +379,13 @@ export function placeTasks(
190
379
  else placements.remoteOnlyNoop.add(node.id)
191
380
  }
192
381
  }
382
+ for (const servers of placements.servers.values())
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
+ }
193
389
  return placements
194
390
  }
195
391
 
@@ -202,16 +398,20 @@ export function placeTasks(
202
398
  * inspection command and must not fail over a label. The error is still
203
399
  * said, on the status line, in the plugin's name: the run this plan
204
400
  * previews would refuse on it, and a plan that hid that would read as
205
- * "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.
206
404
  */
207
405
  export async function planExecutorOf(
208
406
  prepared: Awaited<ReturnType<typeof prepareRun>>,
209
407
  log: Logger,
210
408
  policy: 'all' | 'toplevel' | 'none',
409
+ ci: boolean,
211
410
  ): Promise<{
212
411
  executorOf?: (id: string) => string | undefined
213
412
  downloadOf?: (id: string) => 'eager' | 'deferred' | 'never' | undefined
214
413
  downloadDowngrades?: ReadonlyArray<{ taskId: string; reason: string }>
414
+ otherPlatform?: ReadonlyMap<string, readonly string[]>
215
415
  }> {
216
416
  let executors: readonly TaskExecutor[]
217
417
  try {
@@ -220,6 +420,8 @@ export async function planExecutorOf(
220
420
  cacheDir: prepared.cacheDir,
221
421
  warn: warnOf(log),
222
422
  concurrency: machineParallelism(),
423
+ artifacts: taskArtifacts(prepared.cache, prepared.workspaceRoot),
424
+ cacheFacts: () => cacheFacts(prepared.remotes, prepared.cacheRefs),
223
425
  })
224
426
  } catch (err) {
225
427
  warnOf(log)(
@@ -228,6 +430,9 @@ export async function planExecutorOf(
228
430
  return {}
229
431
  }
230
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)
231
436
  // Download modes need placement regardless of how many executors there
232
437
  // are (a single REMOTE one still defers); executor LABELS only earn their
233
438
  // column when there is a choice to report.
@@ -241,6 +446,7 @@ export async function planExecutorOf(
241
446
  remoteOnly: placements.remoteOnly,
242
447
  })
243
448
  return {
449
+ ...(placements.otherPlatform.size > 0 ? { otherPlatform: placements.otherPlatform } : {}),
244
450
  ...(executors.length < 2 && placements.remoteOnlyNoop.size === 0
245
451
  ? {}
246
452
  : {
@@ -305,3 +511,40 @@ export function poolOfPlacement(
305
511
  return executor === undefined ? undefined : pools.get(executor)
306
512
  }
307
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 }>