@vzn/vx 0.0.640 → 0.0.641

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.
@@ -27,6 +27,8 @@ import {
27
27
  isInstalledPath,
28
28
  isLiteralPattern,
29
29
  normalizeGlob,
30
+ namesNodeModules,
31
+ OUTPUT_NEVER,
30
32
  outputMatcher,
31
33
  realpathOf,
32
34
  relPosix,
@@ -89,21 +91,8 @@ const ALWAYS_IGNORE = [
89
91
  // must not select every `**` reader under --affected.
90
92
  '**/.vx/.gitattributes',
91
93
  '**/.vx/.gitignore',
92
- // `bun build --compile` writes a transient `.<hash>-<n>.bun-build` intermediate
93
- // in the cwd. It never rests on disk, so it can't be a real input — but a
94
- // broad `inputs.files: ['**/*']` on a compile task would try to hash the temp
95
- // file that a CONCURRENT compile is mid-write, racing to EACCES/ENOENT. Always
96
- // exclude it (vx is Bun-native; compiling standalone binaries is a common task).
97
- '**/*.bun-build',
98
- // …and, cross-compiling for a target its cache lacks, the directory it
99
- // extracts the downloaded runtime into, `<cwd>/.<16 hex>-<8 hex>.tmp/`,
100
- // 60 MB mid-write under the same race (2026-09-29).
101
- '**/.????????????????-????????.tmp/**',
102
- // vx's own restore temp (archive.ts), live only while an artifact lands
103
- // beside its target. A key taken meanwhile listed it, the rename removed
104
- // it, and every task reading that directory was withheld its save
105
- // (withastro/astro: 25 of 33 builds re-ran on a restore, X-209).
106
- '**/.vx-tmp-*',
94
+ // Where restores stage (archive.ts): vx's own, never an input.
95
+ '**/.vx/tmp/**',
107
96
  ]
108
97
 
109
98
  const DEFAULT_FILE_GLOBS: readonly string[] = ['**/*']
@@ -677,17 +666,6 @@ async function resolveRuntimeValues(
677
666
  )
678
667
  }
679
668
 
680
- /**
681
- * What no output glob reaches. An output is otherwise taken as written —
682
- * `node_modules/**` IS an install task's output, so `ALWAYS_IGNORE` does not
683
- * apply here — but no task produces these, and a root project declaring
684
- * `**` would otherwise wipe its repository and the cache it is restoring
685
- * from (2026-09-16). `.vx` whole and each `vx.lock.json` are vx's own: as
686
- * outputs, the clean deleted an uncommitted workspace config and a hit
687
- * restored a lock or ignore file the key does not see over the current one.
688
- */
689
- const OUTPUT_NEVER = ['**/.git/**', '**/.vx/**', '**/vx.lock.json']
690
-
691
669
  /**
692
670
  * What a set of output globs may not reach: `OUTPUT_NEVER`, and every
693
671
  * `node_modules` unless a glob names one. `**\/*.js` meant the build's
@@ -697,8 +675,9 @@ const OUTPUT_NEVER = ['**/.git/**', '**/.vx/**', '**/vx.lock.json']
697
675
  * even `.git`.
698
676
  */
699
677
  function outputExcludes(outputs: readonly string[]): Bun.Glob[] {
700
- const namesNodeModules = outputs.some((o) => normalizeGlob(o).split('/').includes('node_modules'))
701
- return (namesNodeModules ? OUTPUT_NEVER : [...OUTPUT_NEVER, '**/node_modules/**']).map(globFor)
678
+ return (namesNodeModules(outputs) ? OUTPUT_NEVER : [...OUTPUT_NEVER, '**/node_modules/**']).map(
679
+ globFor,
680
+ )
702
681
  }
703
682
 
704
683
  /**
@@ -877,6 +856,7 @@ export async function cleanOutputs(args: {
877
856
  resolved?: readonly string[] | undefined
878
857
  }): Promise<string[]> {
879
858
  const committed = args.committed ?? committedFiles(undefined, args.projectDir)
859
+ sweepRestoreTemps(args.projectDir, args.outputs)
880
860
  const matched = args.resolved ?? (await resolveOutputs(args))
881
861
  // `force: true` makes rm tolerate ENOENT (e.g. when two output
882
862
  // globs overlap and a sibling already deleted a path mid-iteration).
@@ -892,6 +872,70 @@ export async function cleanOutputs(args: {
892
872
  return matched.map((f) => relPosix(args.projectDir, f))
893
873
  }
894
874
 
875
+ /**
876
+ * Remove the restore temps (`.vx-tmp-<pid>-<n>`, archive.ts) a killed run
877
+ * left beside an exact-file output. A restore stages in `.vx/tmp/` and
878
+ * writes one beside its target only where the target is on another
879
+ * filesystem (EXDEV); a glob over the directory takes it with the outputs,
880
+ * but `out/a.txt` reaches no sibling, and a partial temp stayed in `out/`
881
+ * through every later run (Growth's SIGKILL probe, 2026-10-16). A temp
882
+ * whose writer is alive, this process included, is left: it may be
883
+ * mid-restore.
884
+ */
885
+ export function sweepRestoreTemps(base: string, outputs: readonly string[]): void {
886
+ const dirs = new Set<string>()
887
+ for (const g of outputs) {
888
+ if (g.startsWith('!') || !isLiteralPattern(g)) continue
889
+ dirs.add(path.dirname(path.resolve(base, stripTrailingSlash(normalizeGlob(g)))))
890
+ }
891
+ for (const dir of dirs) {
892
+ let names: string[]
893
+ try {
894
+ names = readdirSync(dir)
895
+ } catch {
896
+ continue
897
+ }
898
+ for (const name of names) {
899
+ const m = /^\.vx-tmp-([0-9a-z]+)-[0-9a-z]+$/.exec(name)
900
+ if (m === null) continue
901
+ const pid = Number.parseInt(m[1]!, 36)
902
+ if (pid === process.pid || writerAlive(pid)) continue
903
+ rmSync(path.join(dir, name), { force: true })
904
+ }
905
+ }
906
+ }
907
+
908
+ /**
909
+ * Empty `.vx/tmp/` (archive.ts `STAGING`) of the staging directories
910
+ * (`<pid>-<n>`) a killed run left, once per run. One whose writer is alive,
911
+ * another run's or this one's, is left: it may be mid-restore.
912
+ */
913
+ export function sweepStaging(workspaceRoot: string): void {
914
+ const top = path.join(workspaceRoot, '.vx', 'tmp')
915
+ let names: string[]
916
+ try {
917
+ names = readdirSync(top)
918
+ } catch {
919
+ return
920
+ }
921
+ for (const name of names) {
922
+ const m = /^([0-9a-z]+)-[0-9a-z]+$/.exec(name)
923
+ if (m === null) continue
924
+ const pid = Number.parseInt(m[1]!, 36)
925
+ if (pid === process.pid || writerAlive(pid)) continue
926
+ rmSync(path.join(top, name), { recursive: true, force: true })
927
+ }
928
+ }
929
+
930
+ function writerAlive(pid: number): boolean {
931
+ try {
932
+ process.kill(pid, 0)
933
+ return true
934
+ } catch (err) {
935
+ return (err as NodeJS.ErrnoException).code === 'EPERM'
936
+ }
937
+ }
938
+
895
939
  /**
896
940
  * Remove exactly these project-relative paths — the recorded rows of an
897
941
  * ADDITIVE task's own artifact (item 588), never a glob: the glob would
@@ -1221,6 +1265,7 @@ export async function cleanWorkspaceOutputs(args: {
1221
1265
  resolved?: readonly string[] | undefined
1222
1266
  }): Promise<string[]> {
1223
1267
  const committed = args.committed ?? committedFiles(undefined, args.workspaceRoot)
1268
+ sweepRestoreTemps(args.workspaceRoot, args.outputs)
1224
1269
  const matched = args.resolved ?? (await resolveWorkspaceOutputs(args))
1225
1270
  await pruneEmptiedDirs(
1226
1271
  args.workspaceRoot,
@@ -1324,10 +1369,12 @@ function outright(reach: { prefixes: readonly string[]; literals: readonly strin
1324
1369
  /**
1325
1370
  * The files on disk under the {@link ignoredReach} entries that `accept`
1326
1371
  * keeps, sorted and absolute. A file is hashed from disk like any untracked
1327
- * one; nothing here is stored or restored. An install (`node_modules`)
1328
- * stays out, as in the enumeration: lockfile plugins key it. lstat, not
1329
- * `exists()`: that answers false for a directory. Never memoized: a dependency writes these
1330
- * mid-run.
1372
+ * one; nothing here is stored or restored. `node_modules` is no special
1373
+ * case (owner, 2026-10-16): an ignored path named outright is an input,
1374
+ * a generated client (`node_modules/.prisma/client/**`) too; skipping
1375
+ * installs here folded such an entry to nothing, silently. lstat, not
1376
+ * `exists()`: that answers false for a directory. Never memoized: a
1377
+ * dependency writes these mid-run.
1331
1378
  */
1332
1379
  function ignoredInputs(
1333
1380
  root: string,
@@ -1340,8 +1387,7 @@ function ignoredInputs(
1340
1387
  const st = lstatSync(abs, { throwIfNoEntry: false })
1341
1388
  if (st === undefined) continue
1342
1389
  if (!st.isDirectory()) {
1343
- if ((st.isFile() || st.isSymbolicLink()) && !isInstalledPath(entry) && accept(entry))
1344
- found.add(abs)
1390
+ if ((st.isFile() || st.isSymbolicLink()) && accept(entry)) found.add(abs)
1345
1391
  continue
1346
1392
  }
1347
1393
  for (const rel of WALK.scanSync({
@@ -1351,7 +1397,7 @@ function ignoredInputs(
1351
1397
  dot: true,
1352
1398
  })) {
1353
1399
  const full = `${entry}/${rel.split(path.sep).join('/')}`
1354
- if (isInstalledPath(full) || !accept(full)) continue
1400
+ if (!accept(full)) continue
1355
1401
  const file = path.join(abs, rel)
1356
1402
  if (isInputOnDisk(file)) found.add(file)
1357
1403
  }
@@ -677,7 +677,13 @@ export interface CacheLayer {
677
677
  * into the workspace root. Callers restoring entries that may carry
678
678
  * workspace outputs must pass `workspaceRoot`.
679
679
  */
680
- restoreOutputs(hash: string, projectDir: string, workspaceRoot?: string): Promise<void>
680
+ restoreOutputs(
681
+ hash: string,
682
+ projectDir: string,
683
+ workspaceRoot?: string,
684
+ /** The task's declared outputs: an entry outside them fails the restore, nothing written. */
685
+ outputs?: DeclaredOutputs,
686
+ ): Promise<void>
681
687
  save(args: {
682
688
  hash: string
683
689
  /**
@@ -21,6 +21,7 @@ import type { Cache } from './cache.js'
21
21
  import type {
22
22
  CacheEntry,
23
23
  CacheGetContext,
24
+ DeclaredOutputs,
24
25
  CacheKeyInput,
25
26
  CacheLayer,
26
27
  CacheStats,
@@ -61,11 +62,6 @@ export interface RemoteCacheLayer {
61
62
  * dropped before it is printed.
62
63
  */
63
64
  readonly endpoint?: string
64
- /**
65
- * What the wire's credential allows, when less than both (a read-only
66
- * token): core clears the run's remote axis it lacks. Omitted → both.
67
- */
68
- readonly access?: { readonly read?: boolean; readonly write?: boolean }
69
65
  /** Existence probe: `--dry` and `--force` ask it per key when the remote cannot batch. */
70
66
  has(hash: string): Promise<boolean>
71
67
  /**
@@ -330,8 +326,13 @@ export class LayeredCache implements CacheLayer {
330
326
  return this.local.hashFile(filePath)
331
327
  }
332
328
 
333
- async restoreOutputs(hash: string, projectDir: string, workspaceRoot?: string): Promise<void> {
334
- await this.local.restoreOutputs(hash, projectDir, workspaceRoot)
329
+ async restoreOutputs(
330
+ hash: string,
331
+ projectDir: string,
332
+ workspaceRoot?: string,
333
+ outputs?: DeclaredOutputs,
334
+ ): Promise<void> {
335
+ await this.local.restoreOutputs(hash, projectDir, workspaceRoot, outputs)
335
336
  }
336
337
 
337
338
  async save(args: SaveArgs): Promise<void> {
@@ -8,6 +8,7 @@ import type {
8
8
  CacheEntry,
9
9
  CacheGetContext,
10
10
  CacheLayer,
11
+ DeclaredOutputs,
11
12
  IngestMeta,
12
13
  InvocationRecord,
13
14
  OutputDirRow,
@@ -194,8 +195,13 @@ export class ScopedCache implements CacheLayer {
194
195
  return (await this.inner.outputDirsCurrent?.(projectDir, rows)) ?? false
195
196
  }
196
197
 
197
- restoreOutputs(hash: string, projectDir: string, workspaceRoot?: string): Promise<void> {
198
- return this.inner.restoreOutputs(this.keyOf(hash), projectDir, workspaceRoot)
198
+ restoreOutputs(
199
+ hash: string,
200
+ projectDir: string,
201
+ workspaceRoot?: string,
202
+ outputs?: DeclaredOutputs,
203
+ ): Promise<void> {
204
+ return this.inner.restoreOutputs(this.keyOf(hash), projectDir, workspaceRoot, outputs)
199
205
  }
200
206
 
201
207
  save(args: SaveArgs): Promise<void> {
package/src/cli/help.ts CHANGED
@@ -117,7 +117,7 @@ export function helpText(pluginCommands: readonly string[] = []): string {
117
117
  ' vx cache clean [--older-than <duration>] [--max-size <size>] [--global] [--dry-run] [--format pretty|json] [--cache-dir <path>]',
118
118
  ' vx lock [--check] [--format pretty|json]',
119
119
  ' vx init [--dry [--format pretty|json]] [--force] [--mjs] [--native|--keep] [--plugin <seam>]',
120
- ' vx upgrade [TAG]',
120
+ ' vx upgrade [TAG] [-g|--global]',
121
121
  ' vx show [PROJECT[#TASK] | TASK] [--filter <pattern>] [--affected[=<ref>]] [--format pretty|json]',
122
122
  ' vx info [--format pretty|json] [--cache-dir <path>]',
123
123
  ' vx why (TASK | PKG#TASK) [--run RUNID] [--format pretty|json] [--cache-dir <path>]',
@@ -274,6 +274,12 @@ export function helpText(pluginCommands: readonly string[] = []): string {
274
274
  ' test (executor, cache, telemetry, schedule, admit, commands,',
275
275
  ' project, graph, key).',
276
276
  '',
277
+ 'Upgrade:',
278
+ ' vx upgrade [TAG] Bring vx to TAG (default: latest) the way it was installed.',
279
+ " In a project: the project's vx, its @vzn/vx-* plugins and",
280
+ ' the global vx that ran it, to one release.',
281
+ ' -g, --global Only the global vx: no project, no plugin.',
282
+ '',
277
283
  'Shell completions:',
278
284
  ' vx completions <shell> Print a completion script for bash, zsh or fish: the',
279
285
  ' verbs (plugin verbs of this workspace included) and',
@@ -712,6 +712,31 @@ export async function upgradeGlobal(
712
712
  })
713
713
  }
714
714
 
715
+ /**
716
+ * `vx upgrade -g`: bring this vx, the global one, to `version` and nothing
717
+ * else (owner, 2026-10-16) — no project's package.json, no plugin. The
718
+ * project's own vx refuses: no global ran.
719
+ */
720
+ export async function upgradeOnlyGlobal(
721
+ self: string,
722
+ version: string,
723
+ current = VERSION,
724
+ ): Promise<void> {
725
+ if (current === version) {
726
+ ui.print('out', ui.status('info', 'vx upgrade', `global already at ${current}`))
727
+ return
728
+ }
729
+ // Read from the binary's own directory, not cwd: a project there is not this install's.
730
+ const install = managedInstall(self, path.dirname(self))
731
+ if (install?.scope === 'project') {
732
+ throw new UserError(
733
+ `vx upgrade -g: no global vx ran — ${self} is the project's own. Install one (npm install -g ${PACKAGE}), or run \`vx upgrade\` to move the project's`,
734
+ )
735
+ }
736
+ if (install === null) await upgradeBinary(self, version, current, 'global ')
737
+ else await upgradeManaged(install, version, current, 'global ')
738
+ }
739
+
715
740
  /** `run`, its refusal naming `what` after the verb: a run that moves two installs says which failed. */
716
741
  async function named(what: string, run: () => Promise<void>): Promise<void> {
717
742
  try {
@@ -722,7 +747,9 @@ async function named(what: string, run: () => Promise<void>): Promise<void> {
722
747
  }
723
748
  }
724
749
 
725
- export async function upgradeCmd(args: readonly string[]): Promise<number> {
750
+ export async function upgradeCmd(argv: readonly string[]): Promise<number> {
751
+ const global = argv.some((a) => a === '-g' || a === '--global')
752
+ const args = argv.filter((a) => a !== '-g' && a !== '--global')
726
753
  const unknown = args.find((a) => a.startsWith('-'))
727
754
  if (unknown !== undefined) {
728
755
  sayNotice(
@@ -746,10 +773,18 @@ export async function upgradeCmd(args: readonly string[]): Promise<number> {
746
773
  )
747
774
  }
748
775
  const dest = process.execPath
776
+ const handedOffFrom = process.env['VX_HANDED_OFF']
777
+ if (global) {
778
+ // A launcher from before -g handed the call over: the global is the one that did.
779
+ const version = await targetVersion(tag)
780
+ if (handedOffFrom !== undefined) await upgradeGlobal(handedOffFrom, version)
781
+ else await upgradeOnlyGlobal(dest, version)
782
+ return 0
783
+ }
749
784
  const install = managedInstall(dest, process.cwd())
750
785
  // A global vx handed this run to the project's own: both move, to one
751
786
  // version, the project first (owner, 2026-10-15).
752
- const from = process.env['VX_HANDED_OFF']
787
+ const from = handedOffFrom
753
788
  if (from !== undefined) {
754
789
  const version = await targetVersion(tag)
755
790
  await named('project vx', () =>
@@ -33,13 +33,8 @@ export function isIgnoredWatchPath(rel: string): boolean {
33
33
  const segments = rel.split(path.sep)
34
34
  for (let i = 1; i <= segments.length; i++) if (isIgnoredDir(segments.slice(0, i))) return true
35
35
  if (IGNORED_SUFFIXES.some((suf) => rel.endsWith(suf))) return true
36
- const name = segments[segments.length - 1]!
37
- // A restore's own temporary file, never an input (`ALWAYS_IGNORE`):
38
- // renamed away before the judgement, it read as a deletion and ran one
39
- // more cycle where an uncached task makes ignored paths count.
40
- if (name.startsWith('.vx-tmp-')) return true
41
36
  // `vx lock`'s review copy: no input, so a re-lock is no cycle.
42
- return name === 'vx.lock.json'
37
+ return segments[segments.length - 1] === 'vx.lock.json'
43
38
  }
44
39
 
45
40
  /**
@@ -16,7 +16,8 @@ export const IGNORED_SEGMENTS = ['node_modules', '.git']
16
16
  export function isIgnoredDir(segments: readonly string[]): boolean {
17
17
  const last = segments.length - 1
18
18
  if (IGNORED_SEGMENTS.includes(segments[last]!)) return true
19
- return segments[last] === 'cache' && segments[last - 1] === '.vx'
19
+ // vx's cache, and where its restores stage (archive.ts).
20
+ return (segments[last] === 'cache' || segments[last] === 'tmp') && segments[last - 1] === '.vx'
20
21
  }
21
22
 
22
23
  /**
package/src/handoff.ts CHANGED
@@ -5,6 +5,11 @@
5
5
  // walk runs before anything else loads, through `Bun.file(…).exists()`:
6
6
  // importing `node:fs` alone was ~1.1 ms of a 4 ms start (2026-10-13).
7
7
 
8
+ /** `vx upgrade -g`: moves only the global vx, so it never hands off (owner, 2026-10-16). */
9
+ export function upgradesOnlyItself(argv: readonly string[]): boolean {
10
+ return argv[0] === 'upgrade' && argv.some((a) => a === '-g' || a === '--global')
11
+ }
12
+
8
13
  /**
9
14
  * The project-local vx to run instead of this one, or null: the workspace
10
15
  * root's ({@link workspaceFile}) `node_modules/@vzn/vx`, run through its
package/src/index.ts CHANGED
@@ -22,7 +22,7 @@ export { machineMemoryBytes, machineParallelism } from './util/index.js'
22
22
  // How core reads a task glob — its spelling, and which characters are
23
23
  // wildcards (`[` `]` are not; item 667) — for a plugin that turns declared
24
24
  // outputs into something else (`@vzn/vx-reapi`'s literal `output_paths`).
25
- export { isLiteralPattern, normalizeGlob } from './util/index.js'
25
+ export { isLiteralPattern, normalizeGlob, restorableOutput } from './util/index.js'
26
26
 
27
27
  // Schema types and helpers (used by user vx.config files and presets).
28
28
  export type {
@@ -12,11 +12,10 @@ import type { CacheConfig } from '../config.js'
12
12
  import {
13
13
  type CacheEntry,
14
14
  cleanOutputPaths,
15
- cleanOutputs,
16
- cleanWorkspaceOutputs,
17
15
  committedFiles,
18
16
  resolveOutputs,
19
17
  resolveWorkspaceOutputs,
18
+ sweepRestoreTemps,
20
19
  WORKSPACE_OUTPUT_PREFIX,
21
20
  } from '../cache/index.js'
22
21
  import type { TaskOutcome } from '../graph/index.js'
@@ -232,34 +231,74 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
232
231
  }
233
232
  }
234
233
  if (!skipRestore) {
235
- let cleanedRels: string[] = []
236
- let cleanedWsRels: string[] = []
237
- if (additive) {
238
- cleanedRels = hit.outputFiles.filter((p) => !p.startsWith(WORKSPACE_OUTPUT_PREFIX))
234
+ const heldRels = hit.outputFiles.filter((p) => !p.startsWith(WORKSPACE_OUTPUT_PREFIX))
235
+ const heldWsRels = hit.outputFiles
236
+ .filter((p) => p.startsWith(WORKSPACE_OUTPUT_PREFIX))
237
+ .map((p) => p.slice(WORKSPACE_OUTPUT_PREFIX.length))
238
+ // In place, never clean-then-restore: each file is renamed over the one
239
+ // it replaces, so a reader of the tree never finds a declared output
240
+ // missing, and a restore that fails before its renames (a full disk,
241
+ // a damaged entry) leaves the outputs it found as they were. Only what blocks a rename
242
+ // goes first; what the entry does not hold goes after. The clean used
243
+ // to run first: a reader polling a 64 MB output while ten hits restored
244
+ // met ENOENT on 153,336 of 204,411 reads (2026-10-16). An ADDITIVE task
245
+ // owns only its rows (item 588), and the restore rewrites exactly those.
246
+ let foundRels: string[] = []
247
+ let foundWsRels: string[] = []
248
+ let staleRels: string[] = []
249
+ let staleWsRels: string[] = []
250
+ if (!additive) {
251
+ sweepRestoreTemps(node.projectDir, outputs)
252
+ sweepRestoreTemps(args.workspaceRoot, wsOutputs)
253
+ if (outputs.length > 0) {
254
+ const abs = walked?.proj ?? (await resolveOutputs(cleanArgs))
255
+ foundRels = abs.map((f) => relPosix(node.projectDir, f))
256
+ const blocking = inTheWay(foundRels, heldRels)
257
+ staleRels = notHeld(foundRels, heldRels).filter((f) => !blocking.has(f))
258
+ if (blocking.size > 0) {
259
+ await cleanOutputPaths({
260
+ projectDir: node.projectDir,
261
+ rels: [...blocking],
262
+ outputs,
263
+ committed: cleanArgs.committed,
264
+ })
265
+ }
266
+ }
267
+ if (wsOutputs.length > 0) {
268
+ const abs = walked?.ws ?? (await resolveWorkspaceOutputs(wsCleanArgs))
269
+ foundWsRels = abs.map((f) => relPosix(args.workspaceRoot, f))
270
+ const blocking = inTheWay(foundWsRels, heldWsRels)
271
+ staleWsRels = notHeld(foundWsRels, heldWsRels).filter((f) => !blocking.has(f))
272
+ if (blocking.size > 0) {
273
+ await cleanOutputPaths({
274
+ projectDir: args.workspaceRoot,
275
+ rels: [...blocking],
276
+ outputs: wsOutputs,
277
+ committed: wsCleanArgs.committed,
278
+ })
279
+ }
280
+ }
281
+ }
282
+ await args.cache.restoreOutputs(hash, node.projectDir, args.workspaceRoot, {
283
+ files: outputs,
284
+ workspaceFiles: wsOutputs,
285
+ })
286
+ if (staleRels.length > 0) {
239
287
  await cleanOutputPaths({
240
288
  projectDir: node.projectDir,
241
- rels: cleanedRels,
289
+ rels: staleRels,
242
290
  outputs,
243
291
  committed: cleanArgs.committed,
244
292
  })
245
- cleanedWsRels = hit.outputFiles
246
- .filter((p) => p.startsWith(WORKSPACE_OUTPUT_PREFIX))
247
- .map((p) => p.slice(WORKSPACE_OUTPUT_PREFIX.length))
293
+ }
294
+ if (staleWsRels.length > 0) {
248
295
  await cleanOutputPaths({
249
296
  projectDir: args.workspaceRoot,
250
- rels: cleanedWsRels,
297
+ rels: staleWsRels,
251
298
  outputs: wsOutputs,
252
299
  committed: wsCleanArgs.committed,
253
300
  })
254
- } else {
255
- if (outputs.length > 0) {
256
- cleanedRels = await cleanOutputs({ ...cleanArgs, resolved: walked?.proj })
257
- }
258
- if (wsOutputs.length > 0) {
259
- cleanedWsRels = await cleanWorkspaceOutputs({ ...wsCleanArgs, resolved: walked?.ws })
260
- }
261
301
  }
262
- await args.cache.restoreOutputs(hash, node.projectDir, args.workspaceRoot)
263
302
  // The tree now holds this entry's bytes: stamp them for the next hit
264
303
  // (item 886).
265
304
  args.cache.recordOutputStamps?.(hash, node.projectDir, args.workspaceRoot)
@@ -288,17 +327,12 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
288
327
  // unconditional drop (an executed task may write undeclared
289
328
  // files only git can see).
290
329
  if (outputs.length > 0) {
291
- args.gitFilesCache?.markOutputsChanged(node.projectDir, [
292
- ...cleanedRels,
293
- ...hit.outputFiles.filter((p) => !p.startsWith(WORKSPACE_OUTPUT_PREFIX)),
294
- ])
330
+ args.gitFilesCache?.markOutputsChanged(node.projectDir, [...foundRels, ...heldRels])
295
331
  }
296
332
  if (wsOutputs.length > 0) {
297
333
  args.gitFilesCache?.markWorkspaceOutputsChanged(args.workspaceRoot, [
298
- ...cleanedWsRels,
299
- ...hit.outputFiles
300
- .filter((p) => p.startsWith(WORKSPACE_OUTPUT_PREFIX))
301
- .map((p) => p.slice(WORKSPACE_OUTPUT_PREFIX.length)),
334
+ ...foundWsRels,
335
+ ...heldWsRels,
302
336
  ])
303
337
  }
304
338
  }
@@ -361,3 +395,43 @@ function dirPrefixesOf(outputs: readonly string[]): readonly string[] | null {
361
395
  }
362
396
 
363
397
  const prefixMemo = new Map<string, readonly string[] | null>()
398
+
399
+ /**
400
+ * On a case-insensitive volume (macOS's default) a rename onto `out.txt`
401
+ * replaces `Out.txt`: the two are one file, and removing the found name
402
+ * after the restore would remove the restored one.
403
+ */
404
+ const fold: (rel: string) => string =
405
+ process.platform === 'darwin' ? (rel) => rel.normalize('NFC').toLowerCase() : (rel) => rel
406
+
407
+ /**
408
+ * The found outputs a rename cannot replace: a file where the entry needs a
409
+ * directory (`dist/out` for `dist/out/a.js`) and a file under a path the
410
+ * entry holds as a file (`dist/out/a.js` for `dist/out`), whose directory
411
+ * the prune then takes.
412
+ */
413
+ function inTheWay(found: readonly string[], held: readonly string[]): Set<string> {
414
+ if (found.length === 0 || held.length === 0) return new Set()
415
+ const files = new Set<string>()
416
+ const dirs = new Set<string>()
417
+ for (const h of held) {
418
+ const key = fold(h)
419
+ files.add(key)
420
+ for (let i = key.indexOf('/'); i !== -1; i = key.indexOf('/', i + 1)) dirs.add(key.slice(0, i))
421
+ }
422
+ const out = new Set<string>()
423
+ for (const f of found) {
424
+ const key = fold(f)
425
+ if (dirs.has(key)) out.add(f)
426
+ for (let i = key.indexOf('/'); i !== -1; i = key.indexOf('/', i + 1)) {
427
+ if (files.has(key.slice(0, i))) out.add(f)
428
+ }
429
+ }
430
+ return out
431
+ }
432
+
433
+ /** The found outputs the entry does not hold: the stale ones, once it has restored. */
434
+ function notHeld(found: readonly string[], held: readonly string[]): string[] {
435
+ const keep = new Set(held.map(fold))
436
+ return found.filter((f) => !keep.has(fold(f)))
437
+ }
@@ -60,6 +60,12 @@ export interface ShortCircuitArgs {
60
60
  hashCache: HashCache
61
61
  /** Cap on concurrent probes — the run's concurrency. */
62
62
  concurrency: number
63
+ /**
64
+ * `cache` is the local store in front of a remote: a hit is certain, a
65
+ * miss is not (the remote may hold it), so only hits are recorded and
66
+ * execute probes every other task through the layers.
67
+ */
68
+ localOnly?: boolean | undefined
63
69
  }
64
70
 
65
71
  /** Per-task result of the up-front probe. `hit` null = a confirmed
@@ -159,6 +165,7 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
159
165
  endProbe()
160
166
  for (const { hash, node } of candidates) {
161
167
  const hit = hits.get(hash) ?? null
168
+ if (hit === null && args.localOnly === true) continue
162
169
  preProbed.set(node.id, { hash, hit })
163
170
  if (hit !== null && !keptOut.has(node.id)) restoreTier.add(node.id)
164
171
  }
@@ -175,6 +182,7 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
175
182
  const { hash, node } = candidates[next++]!
176
183
  try {
177
184
  const hit = await args.cache.get(hash, getContext(node, node.config.exec?.command ?? ''))
185
+ if (hit === null && args.localOnly === true) continue
178
186
  preProbed.set(node.id, { hash, hit })
179
187
  if (hit !== null && !keptOut.has(node.id)) restoreTier.add(node.id)
180
188
  } catch {
@@ -531,8 +531,8 @@ export interface CacheHost extends CacheContext {
531
531
  * Collect every plugin's remote (`cache` hook) in declaration order and
532
532
  * layer each over the local store: a lookup asks the local store first,
533
533
  * then each remote in order (owner, 2026-10-13), and a save reaches every
534
- * remote. The run's policy, narrowed by a remote's `access`, gates each
535
- * remote; a remote's throw is a miss and a warning naming its plugin. No
534
+ * remote. The run's policy alone gates each remote (M, 2026-10-16: a
535
+ * plugin moves bytes, it never decides reads or writes); a remote's throw is a miss and a warning naming its plugin. No
536
536
  * remote → the local store alone.
537
537
  */
538
538
  export async function resolveCache(
@@ -546,11 +546,10 @@ export async function resolveCache(
546
546
  const remote = await safe(plugin, 'cache', () => plugin.cache!(ctx))
547
547
  if (remote === undefined) continue
548
548
  assertShape(plugin, 'cache', remote, REMOTE_CACHE_METHODS, 'a remote cache layer')
549
- const access = remote.access
550
549
  const entry: ResolvedRemote = {
551
550
  remote,
552
- read: policy.remoteRead && access?.read !== false,
553
- write: policy.remoteWrite && access?.write !== false,
551
+ read: policy.remoteRead,
552
+ write: policy.remoteWrite,
554
553
  onError: (err) => ctx.warn(`[vx] plugin '${plugin.name}': ${err.message}`),
555
554
  }
556
555
  resolved.push(entry)