@vzn/vx 0.0.600 → 0.0.602

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.600",
3
+ "version": "0.0.602",
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.600",
25
- "@vzn/vx-linux-arm64": "0.0.600",
26
- "@vzn/vx-darwin-x64": "0.0.600",
27
- "@vzn/vx-darwin-arm64": "0.0.600"
24
+ "@vzn/vx-linux-x64": "0.0.602",
25
+ "@vzn/vx-linux-arm64": "0.0.602",
26
+ "@vzn/vx-darwin-x64": "0.0.602",
27
+ "@vzn/vx-darwin-arm64": "0.0.602"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -867,8 +867,8 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
867
867
  // Wire up readers. We deliberately don't await them — they run for
868
868
  // the child's lifetime. The `ready` promise resolves out-of-band.
869
869
  const [stdout, stderr] = pipes.streams(child)
870
- void Promise.all([consumeChunks(stdout, false), consumeChunks(stderr, true)]).finally(() =>
871
- pipes.close(),
870
+ const readers = Promise.all([consumeChunks(stdout, false), consumeChunks(stderr, true)]).finally(
871
+ () => pipes.close(),
872
872
  )
873
873
 
874
874
  opts.liveChildren?.add(child)
@@ -915,10 +915,19 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
915
915
 
916
916
  // If the child exits BEFORE ready fires, that's a failure to start
917
917
  // — reject the ready promise so the caller can surface it.
918
- void child.exited.then((code) => {
918
+ void child.exited.then(async (code) => {
919
919
  opts.liveChildren?.delete(child)
920
920
  releaseServerGroup(child)
921
921
  if (readyTimer !== undefined) clearTimeout(readyTimer)
922
+ // The exit can land before the readers took the marker it printed on
923
+ // the way out: `echo READY; exit` read as never ready under load. The
924
+ // drain is bounded, as a task's is, for a grandchild holding the pipe.
925
+ if (readyAt === undefined && gaveUpAt === undefined) {
926
+ await Promise.race([
927
+ readers,
928
+ new Promise((resolve) => setTimeout(resolve, POST_EXIT_DRAIN_MS).unref?.()),
929
+ ])
930
+ }
922
931
  if (readyAt === undefined && gaveUpAt === undefined) {
923
932
  rejectReady(
924
933
  new PersistentReadyError(
@@ -1389,7 +1389,7 @@ interface Side {
1389
1389
  node: TaskNode
1390
1390
  globs: readonly string[]
1391
1391
  reads: boolean
1392
- /** The project's root-relative dir for a rebased `files` output. */
1392
+ /** The project's root-relative dir for rebased `files` globs. */
1393
1393
  rel?: string
1394
1394
  }
1395
1395
 
@@ -1407,7 +1407,9 @@ interface Side {
1407
1407
  * scope mirrors `detectOutputCollisions`: `inputs.files` against another
1408
1408
  * same-project task's `outputs.files`; root-anchored `inputs.workspaceFiles`
1409
1409
  * against every other task's `outputs.workspaceFiles` and, with the root
1410
- * known, its `outputs.files` rebased to the root. A task's own outputs are
1410
+ * known, its `outputs.files` rebased to the root; and, with the root known,
1411
+ * `inputs.files` rebased to the root against another task's
1412
+ * `outputs.workspaceFiles`, which may land in any project (X-135). A task's own outputs are
1411
1413
  * already subtracted from its inputs, and an output the reader's `!` entries
1412
1414
  * take back whole is no overlap. A keyed group is exempt: the default
1413
1415
  * `build` reads `**` of a config-less dependency and runs nothing, and its
@@ -1416,6 +1418,8 @@ interface Side {
1416
1418
  function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: string): void {
1417
1419
  const byProject = new Map<string, Side[]>()
1418
1420
  const rooted: Side[] = []
1421
+ const wsWriters: Side[] = []
1422
+ const filesReaders: TaskNode[] = []
1419
1423
  const filesWriters: TaskNode[] = []
1420
1424
  let projectReaders = false
1421
1425
  let rootReaders = false
@@ -1433,12 +1437,17 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
1433
1437
  bucket(n.projectName).push({ node: n, globs: outFiles, reads: false })
1434
1438
  filesWriters.push(n)
1435
1439
  }
1436
- if (outWs.length > 0) rooted.push({ node: n, globs: outWs, reads: false })
1440
+ if (outWs.length > 0) {
1441
+ const side = { node: n, globs: outWs, reads: false }
1442
+ rooted.push(side)
1443
+ wsWriters.push(side)
1444
+ }
1437
1445
  if (isGroupTask(n)) continue
1438
1446
  const inFiles = splitNegations(cache.inputs.files).positive
1439
1447
  const inWs = splitNegations(cache.inputs.workspaceFiles ?? []).positive
1440
1448
  if (inFiles.length > 0) {
1441
1449
  bucket(n.projectName).push({ node: n, globs: inFiles, reads: true })
1450
+ filesReaders.push(n)
1442
1451
  projectReaders = true
1443
1452
  }
1444
1453
  if (inWs.length > 0) {
@@ -1458,6 +1467,23 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
1458
1467
  }
1459
1468
  }
1460
1469
  }
1470
+ if (projectReaders && wsWriters.length > 0 && workspaceRoot !== undefined) {
1471
+ const sides = [...wsWriters]
1472
+ for (const node of filesReaders) {
1473
+ const rel = relPosix(workspaceRoot, node.projectDir)
1474
+ const globs = splitNegations(node.config.cache!.inputs.files).positive.map((g) =>
1475
+ rel === '' ? g : `${rel}/${g}`,
1476
+ )
1477
+ sides.push({ node, globs, reads: true, rel })
1478
+ }
1479
+ for (const [i, j] of overlapCandidates(
1480
+ sides,
1481
+ (s) => s.globs,
1482
+ (s) => s.reads,
1483
+ )) {
1484
+ readsOutputs(sides[i]!, sides[j]!, 'files')
1485
+ }
1486
+ }
1461
1487
  if (!rootReaders) return
1462
1488
  if (workspaceRoot !== undefined) {
1463
1489
  for (const node of filesWriters) {
@@ -1481,6 +1507,8 @@ function readsOutputs(x: Side, y: Side, field: 'files' | 'workspaceFiles'): void
1481
1507
  if (x.reads === y.reads || x.node === y.node) return
1482
1508
  const [reader, writer] = x.reads ? [x, y] : [y, x]
1483
1509
  const cache = reader.node.config.cache!
1510
+ // A reader rebased to the root (X-135) speaks in its project's terms.
1511
+ const at = reader.rel === undefined || reader.rel === '' ? '' : `${reader.rel}/`
1484
1512
  // What the reader's key never reads: its `!` entries and its own outputs.
1485
1513
  const takeBack = [
1486
1514
  ...splitNegations(field === 'files' ? cache.inputs.files : (cache.inputs.workspaceFiles ?? []))
@@ -1488,18 +1516,19 @@ function readsOutputs(x: Side, y: Side, field: 'files' | 'workspaceFiles'): void
1488
1516
  ...splitNegations(
1489
1517
  field === 'files' ? cache.outputs.files : (cache.outputs.workspaceFiles ?? []),
1490
1518
  ).positive,
1491
- ]
1519
+ ].map((g) => at + g)
1492
1520
  for (const go of writer.globs) {
1493
1521
  if (outputTakenBack(go, takeBack)) continue
1494
1522
  for (const gi of reader.globs) {
1495
1523
  if (!outputsOverlap(gi, go)) continue
1496
1524
  const shown =
1497
1525
  writer.rel === undefined || writer.rel === '' ? go : go.slice(writer.rel.length + 1)
1526
+ const out = at !== '' && go.startsWith(at) ? go.slice(at.length) : go
1498
1527
  throw new UserError(
1499
- `${reader.node.id} reads ${JSON.stringify(gi)} in cache.inputs.${field}, which matches ` +
1528
+ `${reader.node.id} reads ${JSON.stringify(gi.slice(at.length))} in cache.inputs.${field}, which matches ` +
1500
1529
  `${writer.node.id}'s output ${JSON.stringify(shown)} — a task's key must not read ` +
1501
1530
  `another task's outputs (the dependency's key already cascades through dependsOn). ` +
1502
- `Exclude it: add ${JSON.stringify(`!${go}`)} to ${reader.node.id}'s ` +
1531
+ `Exclude it: add ${JSON.stringify(`!${out}`)} to ${reader.node.id}'s ` +
1503
1532
  `cache.inputs.${field}, or set rules: { upfrontKeys: false } in vx.workspace.ts to ` +
1504
1533
  `let it wait for its producer.`,
1505
1534
  )
@@ -22,10 +22,13 @@ export type FailureMode = 'stable' | 'flaky-recoverable' | 'flaky-fatal'
22
22
  /**
23
23
  * Per-key outcome projection: every keyed (project, task, hash) under
24
24
  * `source` (`runs`, or a relation with its `id`, `project`, `task`, `hash`,
25
- * `status`, `cache_hit` columns) with how often it failed, how often it
25
+ * `status`, `cache_hit`, `cached` columns) with how often it failed, how often it
26
26
  * passed — an executed success or a cache hit, which replays a success —
27
- * and how often it failed AFTER its first pass (`relapses`). Every
28
- * flakiness reader is a filter over this one projection.
27
+ * and how often it failed AFTER its first pass (`relapses`). A task with
28
+ * no cache block keys on its config alone and runs every time, so its key
29
+ * says nothing about its inputs: no reader counts it, as no run's row
30
+ * calls it flaky. Every flakiness reader is a filter over this one
31
+ * projection.
29
32
  */
30
33
  function keyOutcomesSql(source: string, where = ''): string {
31
34
  return `SELECT project, task, hash, SUM(failed) AS failures, SUM(passed) AS passes,
@@ -39,7 +42,7 @@ function keyOutcomesSql(source: string, where = ''): string {
39
42
  CASE WHEN status = 'success' OR status IN ${HIT_STATUSES} OR cache_hit = 1
40
43
  THEN 1 ELSE 0 END AS passed
41
44
  FROM ${source}
42
- WHERE ${KEYED_RUNS_SQL}${where}))
45
+ WHERE ${KEYED_RUNS_SQL} AND cached IS NOT 0${where}))
43
46
  GROUP BY project, task, hash`
44
47
  }
45
48
 
@@ -14,6 +14,8 @@ import { isCacheHit, taskTelemetryOf, type TaskTelemetry } from './telemetry.js'
14
14
 
15
15
  export interface RunRecordsInput {
16
16
  outcomes: readonly TaskOutcome[]
17
+ /** Tasks that ran behind a failed dependency (`--continue=always`). */
18
+ tainted?: ReadonlySet<string>
17
19
  runId: string
18
20
  /** Wall-clock ms at run start; every per-task ns offset is anchored to it. */
19
21
  startedAtMs: number
@@ -66,7 +68,11 @@ export function assembleRunRecords(input: RunRecordsInput): RunRecords {
66
68
  if (o.status === 'aborted') continue
67
69
  if (input.withTelemetry) telemetryTasks.push(taskTelemetryOf(o))
68
70
  runs.push({
69
- ...(o.hash !== undefined ? { hash: o.hash } : {}),
71
+ // A failure behind a failed dependency is recorded keyless (`''`, which
72
+ // the key readers skip): the flaky list asks a key whether it failed.
73
+ ...(o.hash !== undefined && !(o.status === 'failed' && input.tainted?.has(o.node.id) === true)
74
+ ? { hash: o.hash }
75
+ : {}),
70
76
  project: o.node.projectName,
71
77
  task: o.node.taskName,
72
78
  status: o.status,
@@ -939,6 +939,7 @@ async function runOnBus(
939
939
  // dispatch, and the tracker carries it to what is built on it.
940
940
  const taintSeeds = new Set(excluded.seeds)
941
941
  const taint = taintTracker(options.continueMode === 'always', taintSeeds, nodes)
942
+ const taintedRan = new Set<string>()
942
943
  const dependedOn = new Set<string>()
943
944
  for (const n of nodes.values()) for (const d of n.deps) dependedOn.add(d)
944
945
 
@@ -959,6 +960,7 @@ async function runOnBus(
959
960
  )
960
961
  taintSeeds.add(node.id)
961
962
  const tainted = taint.judge(node, upstream)
963
+ if (tainted) taintedRan.add(node.id)
962
964
  return {
963
965
  node,
964
966
  upstream,
@@ -1027,7 +1029,7 @@ async function runOnBus(
1027
1029
  const historyDb = prepared.localCache.dbHandle()
1028
1030
  const cacheOff = cachesNothing(policy)
1029
1031
  const judgeFlaky = (o: TaskOutcome): void => {
1030
- const candidates = flakyCandidates([o])
1032
+ const candidates = flakyCandidates([o], taintedRan)
1031
1033
  if (candidates.length === 0) return
1032
1034
  try {
1033
1035
  const found = detectFlaky(historyDb, candidates)[0]
@@ -1212,6 +1214,7 @@ async function runOnBus(
1212
1214
  const recordsOf = (final: TaskOutcome[], runOk: boolean) =>
1213
1215
  assembleRunRecords({
1214
1216
  outcomes: final,
1217
+ tainted: taintedRan,
1215
1218
  runId,
1216
1219
  startedAtMs: endedAtMsAtStart,
1217
1220
  endedAtMs,
@@ -1759,7 +1762,10 @@ export function projectNamed(
1759
1762
  }
1760
1763
 
1761
1764
  /** The executed, keyed outcomes of a run — what flakiness is judged on. */
1762
- function flakyCandidates(outcomes: readonly TaskOutcome[]): FlakyCandidate[] {
1765
+ function flakyCandidates(
1766
+ outcomes: readonly TaskOutcome[],
1767
+ tainted: ReadonlySet<string>,
1768
+ ): FlakyCandidate[] {
1763
1769
  const out: FlakyCandidate[] = []
1764
1770
  for (const o of outcomes) {
1765
1771
  if (o.status !== 'success' && o.status !== 'failed') continue
@@ -1768,6 +1774,9 @@ function flakyCandidates(outcomes: readonly TaskOutcome[]): FlakyCandidate[] {
1768
1774
  // and runs every time, so one bad network day would read as a flake for
1769
1775
  // thirty days. Groups do no work.
1770
1776
  if (o.hash === undefined || o.node.config.cache === undefined || isGroupTask(o.node)) continue
1777
+ // Behind a failed dependency the failure is the dependency's: the key it
1778
+ // had passed on read as a flake for thirty days.
1779
+ if (o.status === 'failed' && tainted.has(o.node.id)) continue
1771
1780
  out.push({
1772
1781
  project: o.node.projectName,
1773
1782
  task: o.node.taskName,