@vzn/vx 0.0.556 → 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.556",
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.556",
25
- "@vzn/vx-linux-arm64": "0.0.556",
26
- "@vzn/vx-darwin-x64": "0.0.556",
27
- "@vzn/vx-darwin-arm64": "0.0.556"
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 {
@@ -692,10 +701,17 @@ export async function cleanOutputPaths(args: {
692
701
  await pruneEmptiedDirs(args.projectDir, await removeAll(files, args.projectDir))
693
702
  }
694
703
 
695
- /** A file's identity for the additive diff: what the hit path's fingerprint trusts too. */
704
+ /**
705
+ * A file's identity for the additive diff: what the hit path's
706
+ * `isOutputsCurrent` trusts too. Size and mtime alone missed a rewrite to
707
+ * bytes of the same length with the mtime stamped back; the inode and
708
+ * ctime are what no task sets (item 886).
709
+ */
696
710
  export interface OutputStamp {
697
711
  size: number
698
712
  mtimeMs: number
713
+ ino: number
714
+ ctimeMs: number
699
715
  }
700
716
 
701
717
  /**
@@ -724,7 +740,7 @@ function stampFiles(files: readonly string[]): Map<string, OutputStamp> {
724
740
  for (const f of files) {
725
741
  try {
726
742
  const st = lstatSync(f)
727
- out.set(f, { size: st.size, mtimeMs: st.mtimeMs })
743
+ out.set(f, { size: st.size, mtimeMs: st.mtimeMs, ino: st.ino, ctimeMs: st.ctimeMs })
728
744
  } catch {
729
745
  // Gone between the walk and the stat: not a file the task found.
730
746
  }
@@ -734,10 +750,10 @@ function stampFiles(files: readonly string[]): Map<string, OutputStamp> {
734
750
 
735
751
  /**
736
752
  * The files an additive task's run ADDED or CHANGED under its declared
737
- * outputs: every selected file that was not in `before`, or whose size or
738
- * mtime moved. Size + mtime is the proof the hit path already trusts for
739
- * a current tree, so no new trust is introduced. A file the run rewrote
740
- * with identical bytes counts as its own (the mtime moved), which is the
753
+ * outputs: every selected file that was not in `before`, or whose stamp
754
+ * (`OutputStamp`) moved — the proof the hit path trusts for a current
755
+ * tree, so no new trust is introduced. A file the run rewrote, even with
756
+ * identical bytes, counts as its own (its ctime moved), which is the
741
757
  * rewrite-in-place cost the design note records.
742
758
  *
743
759
  * Undefined when the run REMOVED a file it found: an artifact holds what a
@@ -774,7 +790,13 @@ function changedSince(
774
790
  try {
775
791
  const st = lstatSync(f)
776
792
  kept++
777
- if (st.size !== was.size || st.mtimeMs !== was.mtimeMs) own.push(f)
793
+ if (
794
+ st.size !== was.size ||
795
+ st.mtimeMs !== was.mtimeMs ||
796
+ st.ino !== was.ino ||
797
+ st.ctimeMs !== was.ctimeMs
798
+ )
799
+ own.push(f)
778
800
  } catch {
779
801
  // Vanished since the walk: removed, as one the walk missed is.
780
802
  }
@@ -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,