@vzn/vx 0.0.557 → 0.0.558

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.557",
3
+ "version": "0.0.558",
4
4
  "description": "The fastest task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,10 +21,10 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.557",
25
- "@vzn/vx-linux-arm64": "0.0.557",
26
- "@vzn/vx-darwin-x64": "0.0.557",
27
- "@vzn/vx-darwin-arm64": "0.0.557"
24
+ "@vzn/vx-linux-x64": "0.0.558",
25
+ "@vzn/vx-linux-arm64": "0.0.558",
26
+ "@vzn/vx-darwin-x64": "0.0.558",
27
+ "@vzn/vx-darwin-arm64": "0.0.558"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -133,6 +133,13 @@ export interface ResolveInputsArgs {
133
133
  * only — global dedup so a root-level probe spawns once per run.
134
134
  */
135
135
  workspaceRuntimeCache?: Map<string, Promise<string>>
136
+ /**
137
+ * Answer this task's probes apart from every other task's, in both memos
138
+ * above: the probes may read what its upstream wrote this run, so an
139
+ * answer another task took before that write is not this task's (X-34).
140
+ * The task id.
141
+ */
142
+ runtimeScope?: string
136
143
  /**
137
144
  * Run-scoped memo for `cache.inputs.workspaceFiles`, keyed by the
138
145
  * declaration and valid for one enumeration snapshot. A Turbo-mapped
@@ -207,14 +214,16 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
207
214
  args.projectDir,
208
215
  projectBinDirs(args.projectDir, args.workspaceRoot),
209
216
  args.runtimeCache,
210
- `${args.projectDir}\0`,
217
+ args.runtimeScope === undefined
218
+ ? `${args.projectDir}\0`
219
+ : `${args.projectDir}\0${args.runtimeScope}\0`,
211
220
  ),
212
221
  resolveRuntimeValues(
213
222
  wsRuntimeDecl,
214
223
  args.workspaceRoot,
215
224
  [path.join(args.workspaceRoot, 'node_modules', '.bin')],
216
225
  args.workspaceRuntimeCache,
217
- '',
226
+ args.runtimeScope === undefined ? '' : `\0${args.runtimeScope}\0`,
218
227
  ),
219
228
  ])
220
229
  return {
@@ -167,6 +167,8 @@ export interface ExecuteArgs {
167
167
  gitFilesCache?: GitFilesCache
168
168
  /** Per-run memo for derived hashes (package.json bytes + task config). */
169
169
  hashCache?: HashCache
170
+ /** The run's `probesAfterWrites`, handed to every key this task takes. */
171
+ probesAfterWrites?: ReadonlySet<string>
170
172
  /**
171
173
  * Up-front probe result from the local short-circuit classify, when
172
174
  * this task was stable + cacheable + local-read. Reused here so there
@@ -715,6 +717,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
715
717
  nestedProjectDirs: args.nestedProjectDirs,
716
718
  ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
717
719
  ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
720
+ ...(args.probesAfterWrites !== undefined
721
+ ? { probesAfterWrites: args.probesAfterWrites }
722
+ : {}),
718
723
  }))
719
724
 
720
725
  // The local no-op half of `exec.remote: 'only'`: no remote executor took
@@ -814,6 +819,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
814
819
  captureInto: captured,
815
820
  ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
816
821
  ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
822
+ ...(args.probesAfterWrites !== undefined
823
+ ? { probesAfterWrites: args.probesAfterWrites }
824
+ : {}),
817
825
  })
818
826
  : undefined
819
827
  // A name only `exec.env.secret` makes secret is not one the name rule
@@ -50,7 +50,7 @@ export function keyedProjects(
50
50
  continue
51
51
  }
52
52
  if (deps === undefined) {
53
- frame[1] = folded(node, nodeOf)
53
+ frame[1] = foldedDeps(node, nodeOf)
54
54
  for (const { node: dep } of frame[1]) if (!below.has(dep.id)) stack.push([dep, undefined])
55
55
  continue
56
56
  }
@@ -75,7 +75,7 @@ export function keyedProjects(
75
75
  * edges and lacked the dropped ones: the sandbox granted a sibling the key
76
76
  * does not answer for.
77
77
  */
78
- function folded(node: TaskNode, nodeOf: (id: string) => TaskNode): FoldCandidate[] {
78
+ export function foldedDeps(node: TaskNode, nodeOf: (id: string) => TaskNode): FoldCandidate[] {
79
79
  const candidates: FoldCandidate[] = []
80
80
  for (const id of keyedDeps(node)) {
81
81
  const dep = nodeOf(id)
@@ -86,6 +86,7 @@ import {
86
86
  } from './run-context.js'
87
87
  import { startRemotePrefetch } from './remote-prefetch.js'
88
88
  import { startLocalShortCircuit, type ShortCircuit } from './local-shortcircuit.js'
89
+ import { probesAfterWrites } from './stable-keys.js'
89
90
 
90
91
  import { assembleRunRecords } from './run-records.js'
91
92
  import { hasEnded, selectKeepAlive, shutdownPersistent } from './persistent.js'
@@ -927,6 +928,7 @@ async function runOnBus(
927
928
  // when a sink exists.
928
929
  const explainMiss =
929
930
  telemetry === undefined ? undefined : createMissExplainer(prepared.localCache.dbHandle())
931
+ const lateProbes = probesAfterWrites(nodes, workspaceRoot)
930
932
  const buildExecuteArgs = (node: TaskNode, upstream: TaskOutcome[], reuseProbe = true) => {
931
933
  const probe = reuseProbe ? shortCircuit.preProbed.get(node.id) : undefined
932
934
  const upfrontKey = shortCircuit.uncachedKeys.get(node.id)
@@ -961,6 +963,7 @@ async function runOnBus(
961
963
  ...(explainMiss !== undefined ? { explainMiss } : {}),
962
964
  gitFilesCache,
963
965
  hashCache,
966
+ probesAfterWrites: lateProbes,
964
967
  ...(probe !== undefined ? { preProbed: probe } : {}),
965
968
  ...(upfrontKey !== undefined ? { upfrontKey } : {}),
966
969
  ...(tainted ? { taintedUpstream: true } : {}),
@@ -988,6 +991,7 @@ async function runOnBus(
988
991
  nestedDirsByProject,
989
992
  gitFilesCache,
990
993
  hashCache,
994
+ probesAfterWrites: lateProbes,
991
995
  },
992
996
  buildExecuteArgs,
993
997
  })
@@ -15,6 +15,7 @@ import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
15
15
  import type { CacheLayer, GitFilesCache } from '../cache/index.js'
16
16
  import { isLiteralPattern, normalizeGlob, relPosix } from '../util/index.js'
17
17
  import { commandWriteReach, mayWriteFingerprint, undeclaredWriteReach } from './sandbox-request.js'
18
+ import { foldedDeps } from './keyed-projects.js'
18
19
  import { computeGroupKey, computeTaskHash, type HashCache } from './task-hash.js'
19
20
  import { filterUpstreamHashes, keyUpstream } from './upstream.js'
20
21
 
@@ -53,6 +54,7 @@ export interface StableKey {
53
54
  */
54
55
  export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<StableKey[]> {
55
56
  const order = topoOrder(args.nodes)
57
+ const late = probesAfterWrites(args.nodes, args.workspaceRoot)
56
58
  const keyById = new Map<string, string>()
57
59
  const unstableById = new Set<string>()
58
60
  // Transitive-upstream output producers, accumulated in topo order — the
@@ -203,6 +205,13 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
203
205
  if (unfolded !== undefined) unfoldedById.set(id, unfolded)
204
206
  if (wsRewriter) wsRewriters.add(id)
205
207
 
208
+ // Its probes answer only once its writers have run, so it has no key
209
+ // up front, and its dependants inherit that.
210
+ if (late.has(id)) {
211
+ unstableById.add(id)
212
+ continue
213
+ }
214
+
206
215
  if (isGroupTask(node)) {
207
216
  // Groups have no exec/cache; they only fold upstream keys so
208
217
  // dependents that filter inputs.tasks through the group still
@@ -271,6 +280,86 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
271
280
  return stableKeys
272
281
  }
273
282
 
283
+ const probesAfterWritesOf = new WeakMap<ReadonlyMap<string, TaskNode>, ReadonlySet<string>>()
284
+
285
+ /**
286
+ * The cached tasks whose `cache.inputs.runtime` / `workspaceRuntime` probes
287
+ * may read what an upstream writes this run while the key folds nothing of
288
+ * that upstream (X-34). A probe is a shell command and may read anywhere,
289
+ * so no glob bounds it, and its answer taken before the writer ran is the
290
+ * previous run's bytes. Such a task is never stable (`deriveStableKeys`
291
+ * takes no key for it), and its probes are answered for it alone, after
292
+ * its upstream ran (`ComputeHashArgs.probesAfterWrites`).
293
+ *
294
+ * A writer is any upstream whose command may write (`commandWriteReach`)
295
+ * or that declares outputs. A key that folds the writer's covers what it
296
+ * writes, as for a rewriter in `deriveStableKeys`: an early answer then
297
+ * costs a spurious miss on the run after the bytes change, never a stale
298
+ * hit, and such a probe keeps the run's shared answer — one spawn per run,
299
+ * not per task. So only a writer the key does not fold, transitively
300
+ * (`tasks: []`, a filter that leaves it out, an order-only edge), makes
301
+ * the probe wait.
302
+ *
303
+ * Once per graph: run, admission and both up-front passes ask the same map.
304
+ */
305
+ export function probesAfterWrites(
306
+ nodes: ReadonlyMap<string, TaskNode>,
307
+ workspaceRoot: string,
308
+ ): ReadonlySet<string> {
309
+ const memo = probesAfterWritesOf.get(nodes)
310
+ if (memo !== undefined) return memo
311
+ const late = new Set<string>()
312
+ probesAfterWritesOf.set(nodes, late)
313
+ const probes = (n: TaskNode): boolean =>
314
+ (n.config.cache?.inputs?.runtime?.length ?? 0) > 0 ||
315
+ (n.config.cache?.inputs?.workspaceRuntime?.length ?? 0) > 0
316
+ let anyProbe = false
317
+ for (const n of nodes.values()) {
318
+ if (probes(n)) {
319
+ anyProbe = true
320
+ break
321
+ }
322
+ }
323
+ if (!anyProbe) return late
324
+ // A sandbox with no write grant still writes the declared outputs. A
325
+ // fingerprinted file is only writable where `commandWriteReach` is not
326
+ // 'none' already (`mayWriteFingerprint`).
327
+ const writers = new Set<string>()
328
+ for (const n of nodes.values()) {
329
+ if (
330
+ commandWriteReach(n, workspaceRoot) !== 'none' ||
331
+ (n.config.cache?.outputs.files.length ?? 0) > 0 ||
332
+ (n.config.cache?.outputs.workspaceFiles?.length ?? 0) > 0
333
+ )
334
+ writers.add(n.id)
335
+ }
336
+ const nodeOf = (id: string): TaskNode => nodes.get(id)!
337
+ // Any writer runs before the task; one its key does not fold does.
338
+ const writerBelow = new Set<string>()
339
+ const unfoldedBelow = new Set<string>()
340
+ for (const id of topoOrder(nodes)) {
341
+ const node = nodes.get(id)!
342
+ let writer = false
343
+ let unfolded = false
344
+ // No filter and no order-only edge: the key folds every dependency.
345
+ const foldsAll = node.orderOnly === undefined && node.config.cache?.inputs?.tasks === undefined
346
+ let folds: Set<string> | undefined
347
+ for (const dep of node.deps) {
348
+ if (!nodes.has(dep)) continue
349
+ const depWrites = writerBelow.has(dep) || writers.has(dep)
350
+ if (depWrites) writer = true
351
+ if (unfolded || !depWrites) continue
352
+ const folded =
353
+ foldsAll || (folds ??= new Set(foldedDeps(node, nodeOf).map((c) => c.node.id))).has(dep)
354
+ unfolded = folded ? unfoldedBelow.has(dep) : true
355
+ }
356
+ if (writer) writerBelow.add(id)
357
+ if (unfolded) unfoldedBelow.add(id)
358
+ if (unfolded && node.config.cache !== undefined && probes(node)) late.add(id)
359
+ }
360
+ return late
361
+ }
362
+
274
363
  /**
275
364
  * Does this task's key fold the dependency's? What `computeTaskHash` folds
276
365
  * (`filterUpstreamHashes` over the same synthetic outcomes); a group folds
@@ -519,7 +608,7 @@ class ProjectSet implements ProjectNames {
519
608
  }
520
609
  }
521
610
 
522
- function topoOrder(nodes: Map<string, TaskNode>): string[] {
611
+ function topoOrder(nodes: ReadonlyMap<string, TaskNode>): string[] {
523
612
  const indegree = new Map<string, number>()
524
613
  const dependents = new Map<string, string[]>()
525
614
  for (const node of nodes.values()) {
@@ -88,6 +88,12 @@ export interface ComputeHashArgs {
88
88
  * site, so the recorded set can't drift from the key.
89
89
  */
90
90
  captureInto?: TaskInputComponent[]
91
+ /**
92
+ * The run's tasks whose runtime probes may read an upstream's writes
93
+ * (`probesAfterWrites`): such a task's probes are answered for it alone,
94
+ * after its upstream ran, never from the run's shared memo.
95
+ */
96
+ probesAfterWrites?: ReadonlySet<string>
91
97
  }
92
98
 
93
99
  /**
@@ -268,6 +274,7 @@ async function resolveKeyInput(args: ComputeHashArgs): Promise<CacheKeyInput> {
268
274
  ownWorkspaceOutputs: cacheCfg?.outputs.workspaceFiles ?? [],
269
275
  nestedProjectDirs: args.nestedProjectDirs,
270
276
  ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
277
+ ...(args.probesAfterWrites?.has(args.node.id) === true ? { runtimeScope: args.node.id } : {}),
271
278
  ...(args.hashCache !== undefined
272
279
  ? {
273
280
  runtimeCache: args.hashCache.runtime,