@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.
|
|
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.
|
|
25
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
26
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
27
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
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"
|
package/src/exec/runner.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
package/src/graph/task-graph.ts
CHANGED
|
@@ -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
|
|
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
|
|
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)
|
|
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(`!${
|
|
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`).
|
|
28
|
-
*
|
|
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
|
-
|
|
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,
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -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(
|
|
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,
|