@vzn/vx 0.0.595 → 0.0.596
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/README.md +1 -1
- package/package.json +5 -5
- package/src/cache/cache.ts +139 -84
- package/src/cache/chained-cache.ts +25 -7
- package/src/cache/index.ts +1 -0
- package/src/cache/inputs.ts +12 -12
- package/src/cache/layer.ts +7 -5
- package/src/cache/policy.ts +5 -0
- package/src/cache/zstd.ts +10 -4
- package/src/cli/cache.ts +10 -4
- package/src/cli/foreign-flags.ts +1 -1
- package/src/cli/last.ts +4 -1
- package/src/cli/run.ts +31 -15
- package/src/cli/watch-filter.ts +63 -12
- package/src/cli/watch-judge.ts +15 -8
- package/src/cli/watch.ts +4 -4
- package/src/config.ts +21 -21
- package/src/exec/proc-sample.ts +1 -1
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +116 -40
- package/src/exec/sandbox-violations.ts +58 -11
- package/src/graph/scheduler.ts +28 -12
- package/src/graph/task-graph.ts +134 -25
- package/src/orchestrator/admission.ts +12 -3
- package/src/orchestrator/events.ts +10 -1
- package/src/orchestrator/execute-task.ts +36 -13
- package/src/orchestrator/failure-mode.ts +8 -2
- package/src/orchestrator/framed-output.ts +3 -3
- package/src/orchestrator/logger.ts +11 -6
- package/src/orchestrator/metrics.ts +19 -8
- package/src/orchestrator/miss-save.ts +2 -2
- package/src/orchestrator/options.ts +7 -0
- package/src/orchestrator/plan.ts +3 -3
- package/src/orchestrator/plugin.ts +37 -16
- package/src/orchestrator/prepare.ts +13 -4
- package/src/orchestrator/run-artifacts.ts +2 -1
- package/src/orchestrator/run.ts +20 -4
- package/src/orchestrator/sandbox-request.ts +65 -9
- package/src/orchestrator/save-lane.ts +5 -5
- package/src/orchestrator/summary.ts +2 -4
- package/src/orchestrator/telemetry.ts +16 -7
- package/src/util/paths.ts +23 -3
- package/src/workspace/config-imports.ts +7 -5
- package/src/workspace/config-schema.ts +26 -3
- package/src/workspace/filter.ts +19 -0
- package/src/workspace/migrate-scripts.ts +4 -2
- package/src/workspace/migration.ts +1 -1
- package/src/workspace/project-loader.ts +4 -2
- package/src/workspace/repo-id.ts +42 -4
- package/src/workspace/workspace.ts +17 -10
package/src/graph/task-graph.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import path from 'node:path'
|
|
2
1
|
import type { ProjectConfig, TaskConfig, WorkspaceRules } from '../config.js'
|
|
3
2
|
import {
|
|
4
3
|
asTrees,
|
|
@@ -109,6 +108,11 @@ export function isGroupTask(node: TaskNode): boolean {
|
|
|
109
108
|
return node.config.exec === undefined
|
|
110
109
|
}
|
|
111
110
|
|
|
111
|
+
/** The default `build` (orchestrator/projects.ts): a config cannot key a group, so it is the one keyed group. */
|
|
112
|
+
function isKeyedGroup(task: TaskConfig): boolean {
|
|
113
|
+
return task.exec === undefined && task.cache !== undefined
|
|
114
|
+
}
|
|
115
|
+
|
|
112
116
|
/**
|
|
113
117
|
* Mark, for focused-flow display, the real tasks a requested GROUP
|
|
114
118
|
* stands for. A group has no output of its own, so `vx run build`
|
|
@@ -160,7 +164,8 @@ export function markSurfacedDeps(nodes: Map<string, TaskNode>): number {
|
|
|
160
164
|
*
|
|
161
165
|
* - Bare task names (`'build'`) → one entry per project in
|
|
162
166
|
* `candidates` that declares the task. Missing in a given project
|
|
163
|
-
* is silent (sparse tasks are normal across a workspace).
|
|
167
|
+
* is silent (sparse tasks are normal across a workspace). A task
|
|
168
|
+
* `unselected` holds (the default `build`) is no match.
|
|
164
169
|
* - Anchored entries (`'pkg#task'`) → one entry exactly, ignoring
|
|
165
170
|
* `candidates`. Silently dropped if pkg/task doesn't exist (the
|
|
166
171
|
* CLI's pre-validation catches malformed strings).
|
|
@@ -171,6 +176,7 @@ export function expandRequested(
|
|
|
171
176
|
tasks: readonly string[],
|
|
172
177
|
candidates: readonly string[],
|
|
173
178
|
projects: Map<string, ProjectEntry>,
|
|
179
|
+
unselected?: (task: TaskConfig) => boolean,
|
|
174
180
|
): Array<{ project: string; task: string }> {
|
|
175
181
|
const seen = new Set<string>()
|
|
176
182
|
const out: Array<{ project: string; task: string }> = []
|
|
@@ -189,7 +195,7 @@ export function expandRequested(
|
|
|
189
195
|
continue
|
|
190
196
|
}
|
|
191
197
|
for (const name of candidates) {
|
|
192
|
-
if (
|
|
198
|
+
if (selectsTask(projects, name, spec, unselected)) push(name, spec)
|
|
193
199
|
}
|
|
194
200
|
}
|
|
195
201
|
return out
|
|
@@ -214,6 +220,17 @@ function declaresTask(projects: Map<string, ProjectEntry>, project: string, task
|
|
|
214
220
|
return declaredTask(projects.get(project)?.config, task) !== undefined
|
|
215
221
|
}
|
|
216
222
|
|
|
223
|
+
/** What a bare name matches: a declared task `unselected` leaves alone. */
|
|
224
|
+
function selectsTask(
|
|
225
|
+
projects: Map<string, ProjectEntry>,
|
|
226
|
+
project: string,
|
|
227
|
+
task: string,
|
|
228
|
+
unselected: ((task: TaskConfig) => boolean) | undefined,
|
|
229
|
+
): boolean {
|
|
230
|
+
const config = declaredTask(projects.get(project)?.config, task)
|
|
231
|
+
return config !== undefined && unselected?.(config) !== true
|
|
232
|
+
}
|
|
233
|
+
|
|
217
234
|
/**
|
|
218
235
|
* The requested specs `expandRequested` silently dropped — each one
|
|
219
236
|
* matched NO project, so nothing it asked for will run.
|
|
@@ -230,6 +247,7 @@ export function unresolvedRequests(
|
|
|
230
247
|
tasks: readonly string[],
|
|
231
248
|
candidates: readonly string[],
|
|
232
249
|
projects: Map<string, ProjectEntry>,
|
|
250
|
+
unselected?: (task: TaskConfig) => boolean,
|
|
233
251
|
): string[] {
|
|
234
252
|
const out: string[] = []
|
|
235
253
|
const seen = new Set<string>()
|
|
@@ -239,7 +257,8 @@ export function unresolvedRequests(
|
|
|
239
257
|
const resolved =
|
|
240
258
|
idx >= 0
|
|
241
259
|
? declaresTask(projects, spec.slice(0, idx), spec.slice(idx + 1))
|
|
242
|
-
: candidates.length === 0 ||
|
|
260
|
+
: candidates.length === 0 ||
|
|
261
|
+
candidates.some((name) => selectsTask(projects, name, spec, unselected))
|
|
243
262
|
if (resolved) continue
|
|
244
263
|
seen.add(spec)
|
|
245
264
|
out.push(spec)
|
|
@@ -380,6 +399,55 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
380
399
|
return declared.has(name)
|
|
381
400
|
}
|
|
382
401
|
|
|
402
|
+
// Package cycles, for the default `build` (`isKeyedGroup`): each project
|
|
403
|
+
// on one maps to its strongly connected component, found on the first ask
|
|
404
|
+
// (iterative Tarjan over `directDeps`, the order the `^name` walk reads).
|
|
405
|
+
const component = new Map<string, number>()
|
|
406
|
+
const cyclic = new Set<number>()
|
|
407
|
+
let components = 0
|
|
408
|
+
function componentOf(start: string): number {
|
|
409
|
+
const known = component.get(start)
|
|
410
|
+
if (known !== undefined) return known
|
|
411
|
+
const index = new Map<string, number>()
|
|
412
|
+
const low = new Map<string, number>()
|
|
413
|
+
const open: string[] = []
|
|
414
|
+
const work: Array<[name: string, next: number]> = []
|
|
415
|
+
const enter = (name: string): void => {
|
|
416
|
+
index.set(name, index.size)
|
|
417
|
+
low.set(name, index.size - 1)
|
|
418
|
+
open.push(name)
|
|
419
|
+
work.push([name, 0])
|
|
420
|
+
}
|
|
421
|
+
enter(start)
|
|
422
|
+
while (work.length > 0) {
|
|
423
|
+
const top = work[work.length - 1]!
|
|
424
|
+
const deps = packageGraph.directDeps(top[0])
|
|
425
|
+
if (top[1] < deps.length) {
|
|
426
|
+
const dep = deps[top[1]++]!
|
|
427
|
+
if (component.has(dep)) continue
|
|
428
|
+
if (!index.has(dep)) enter(dep)
|
|
429
|
+
else low.set(top[0], Math.min(low.get(top[0])!, index.get(dep)!))
|
|
430
|
+
continue
|
|
431
|
+
}
|
|
432
|
+
work.pop()
|
|
433
|
+
const name = top[0]
|
|
434
|
+
const parent = work[work.length - 1]
|
|
435
|
+
if (parent !== undefined) low.set(parent[0], Math.min(low.get(parent[0])!, low.get(name)!))
|
|
436
|
+
if (low.get(name) !== index.get(name)) continue
|
|
437
|
+
const id = components++
|
|
438
|
+
let size = 0
|
|
439
|
+
for (let member = ''; member !== name; size++) {
|
|
440
|
+
member = open.pop()!
|
|
441
|
+
component.set(member, id)
|
|
442
|
+
}
|
|
443
|
+
if (size > 1) cyclic.add(id)
|
|
444
|
+
}
|
|
445
|
+
return component.get(start)!
|
|
446
|
+
}
|
|
447
|
+
const onCycle = (name: string): boolean => cyclic.has(componentOf(name))
|
|
448
|
+
const sameCycle = (a: string, b: string): boolean =>
|
|
449
|
+
onCycle(a) && componentOf(a) === componentOf(b)
|
|
450
|
+
|
|
383
451
|
// Resolves one `dependsOn` entry of `frame`'s task into its edges.
|
|
384
452
|
function resolveEntry(frame: Frame, raw: string): void {
|
|
385
453
|
const { node } = frame
|
|
@@ -451,7 +519,14 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
451
519
|
// shape), and a cycle walks the frontier straight back to the
|
|
452
520
|
// origin. Mirrors the self-pattern rule above — a task can never
|
|
453
521
|
// depend on itself.
|
|
522
|
+
//
|
|
523
|
+
// The default `build` sits on every project, so on a package cycle it
|
|
524
|
+
// would make a task cycle no config declares. Its own walk passes
|
|
525
|
+
// through the projects on its cycle (each of their builds reaches it),
|
|
526
|
+
// and a walk that meets it on a cycle takes the edge and goes on past
|
|
527
|
+
// it, so the builds it could not depend on still come first.
|
|
454
528
|
const re = isTaskPattern(spec.task) ? compileTaskPattern(spec.task) : null
|
|
529
|
+
const keyed = isKeyedGroup(node.config)
|
|
455
530
|
const visited = new Set<string>([projectName])
|
|
456
531
|
const frontier = [...packageGraph.directDeps(projectName)]
|
|
457
532
|
let held = false
|
|
@@ -460,8 +535,15 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
460
535
|
if (visited.has(target)) continue
|
|
461
536
|
visited.add(target)
|
|
462
537
|
if (re === null) {
|
|
463
|
-
if (
|
|
464
|
-
|
|
538
|
+
if (keyed && sameCycle(projectName, target)) {
|
|
539
|
+
frontier.push(...packageGraph.directDeps(target))
|
|
540
|
+
} else if (visit(frame, target, spec.task, false)) {
|
|
541
|
+
held = true
|
|
542
|
+
const task = declaredTask(projects.get(target)!.config, spec.task)!
|
|
543
|
+
if (isKeyedGroup(task) && onCycle(target)) {
|
|
544
|
+
frontier.push(...packageGraph.directDeps(target))
|
|
545
|
+
}
|
|
546
|
+
} else frontier.push(...packageGraph.directDeps(target))
|
|
465
547
|
} else {
|
|
466
548
|
const names = Object.keys(projects.get(target)?.config.tasks ?? {}).filter((n) =>
|
|
467
549
|
re.test(n),
|
|
@@ -975,36 +1057,51 @@ const GLOB_HEAD_END = /[*?{}\\!]/
|
|
|
975
1057
|
* directory its own head names. A whole subtree meets the globs whose
|
|
976
1058
|
* literal prefix lies at or under it, found the same way from the glob's
|
|
977
1059
|
* side (item 941).
|
|
1060
|
+
*
|
|
1061
|
+
* Given `sideOf`, only the pairs whose members sit on different sides are
|
|
1062
|
+
* wanted (a reader and a writer, `detectInputOverlaps`), and each index is
|
|
1063
|
+
* kept per side, so a member looks up only the other side's: thousands of
|
|
1064
|
+
* readers sharing one input (`src/**`, a root `tsconfig.base.json`) were
|
|
1065
|
+
* paired with each other, millions of pairs no rule judges (X-101).
|
|
978
1066
|
*/
|
|
979
1067
|
function overlapCandidates<T>(
|
|
980
1068
|
tasks: readonly T[],
|
|
981
1069
|
globsOf: (n: T) => readonly string[] | undefined,
|
|
1070
|
+
sideOf?: (n: T) => boolean,
|
|
982
1071
|
): Array<[number, number]> {
|
|
983
|
-
const
|
|
984
|
-
|
|
1072
|
+
const sides = sideOf === undefined ? 1 : 2
|
|
1073
|
+
// The index a member of side `s` looks up: its own side's, or the other's.
|
|
1074
|
+
const across = (s: number): number => (sides === 1 ? s : 1 - s)
|
|
1075
|
+
const perSide = (): Array<Map<string, number[]>> =>
|
|
1076
|
+
Array.from({ length: sides }, () => new Map<string, number[]>())
|
|
1077
|
+
const globs = perSide()
|
|
1078
|
+
const globsUnder = perSide()
|
|
985
1079
|
const literals: Array<[task: number, path: string]> = []
|
|
986
1080
|
// `covers` (item 941): a whole subtree `P/**` meets every glob whose
|
|
987
1081
|
// literal prefix is P or under it, so each glob looks up the subtrees
|
|
988
1082
|
// filed at its prefix and at each of that prefix's ancestors.
|
|
989
|
-
const subtrees =
|
|
1083
|
+
const subtrees = perSide()
|
|
990
1084
|
const prefixes: Array<[task: number, prefix: string]> = []
|
|
991
1085
|
const file = (index: Map<string, number[]>, key: string, i: number): void => {
|
|
992
1086
|
const list = index.get(key)
|
|
993
1087
|
if (list === undefined) index.set(key, [i])
|
|
994
1088
|
else list.push(i)
|
|
995
1089
|
}
|
|
1090
|
+
const side: number[] = []
|
|
996
1091
|
for (let i = 0; i < tasks.length; i++) {
|
|
1092
|
+
const s = sideOf?.(tasks[i]!) === true ? 1 : 0
|
|
1093
|
+
side.push(s)
|
|
997
1094
|
for (const tree of asTrees(globsOf(tasks[i]!) ?? [])) {
|
|
998
1095
|
if (isLiteralPattern(tree)) {
|
|
999
1096
|
literals.push([i, tree])
|
|
1000
1097
|
continue
|
|
1001
1098
|
}
|
|
1002
|
-
file(globs
|
|
1099
|
+
file(globs[s]!, tree, i)
|
|
1003
1100
|
const dir = wholeSubtreePrefixes([tree])?.[0]
|
|
1004
|
-
if (dir !== undefined) file(subtrees
|
|
1101
|
+
if (dir !== undefined) file(subtrees[s]!, dir, i)
|
|
1005
1102
|
prefixes.push([i, staticPrefix(tree)])
|
|
1006
1103
|
const head = tree.slice(0, tree.search(GLOB_HEAD_END))
|
|
1007
|
-
file(globsUnder
|
|
1104
|
+
file(globsUnder[s]!, head.slice(0, Math.max(0, head.lastIndexOf('/'))), i)
|
|
1008
1105
|
}
|
|
1009
1106
|
}
|
|
1010
1107
|
const n = tasks.length
|
|
@@ -1012,34 +1109,38 @@ function overlapCandidates<T>(
|
|
|
1012
1109
|
const pair = (x: number, y: number): void => {
|
|
1013
1110
|
if (x !== y) pairs.add(x < y ? x * n + y : y * n + x)
|
|
1014
1111
|
}
|
|
1015
|
-
for (const list of globs
|
|
1112
|
+
for (const [tree, list] of globs[0]!) {
|
|
1113
|
+
const other = sides === 1 ? list : (globs[1]!.get(tree) ?? [])
|
|
1016
1114
|
for (let x = 0; x < list.length; x++) {
|
|
1017
|
-
for (let y = x + 1; y <
|
|
1115
|
+
for (let y = sides === 1 ? x + 1 : 0; y < other.length; y++) pair(list[x]!, other[y]!)
|
|
1018
1116
|
}
|
|
1019
1117
|
}
|
|
1020
1118
|
// `witnessed`: two globs meet only when one's literal prefix is the
|
|
1021
1119
|
// other's or under it, so each looks up the globs filed at its prefix
|
|
1022
1120
|
// and at each ancestor.
|
|
1023
|
-
const byPrefix =
|
|
1024
|
-
for (const [i, prefix] of prefixes) file(byPrefix
|
|
1121
|
+
const byPrefix = perSide()
|
|
1122
|
+
for (const [i, prefix] of prefixes) file(byPrefix[side[i]!]!, rootless(prefix), i)
|
|
1025
1123
|
for (const [i, raw] of prefixes) {
|
|
1124
|
+
const index = byPrefix[across(side[i]!)]!
|
|
1026
1125
|
const prefix = rootless(raw)
|
|
1027
|
-
for (const j of
|
|
1028
|
-
if (prefix !== '') for (const j of
|
|
1126
|
+
for (const j of index.get(prefix) ?? []) pair(i, j)
|
|
1127
|
+
if (prefix !== '') for (const j of index.get('') ?? []) pair(i, j)
|
|
1029
1128
|
for (let sep = prefix.indexOf('/'); sep !== -1; sep = prefix.indexOf('/', sep + 1)) {
|
|
1030
|
-
for (const j of
|
|
1129
|
+
for (const j of index.get(prefix.slice(0, sep)) ?? []) pair(i, j)
|
|
1031
1130
|
}
|
|
1032
1131
|
}
|
|
1033
1132
|
for (const [i, prefix] of prefixes) {
|
|
1034
|
-
|
|
1133
|
+
const index = subtrees[across(side[i]!)]!
|
|
1134
|
+
for (const j of index.get(prefix) ?? []) pair(i, j)
|
|
1035
1135
|
for (let sep = prefix.indexOf('/'); sep !== -1; sep = prefix.indexOf('/', sep + 1)) {
|
|
1036
|
-
for (const j of
|
|
1136
|
+
for (const j of index.get(prefix.slice(0, sep)) ?? []) pair(i, j)
|
|
1037
1137
|
}
|
|
1038
1138
|
}
|
|
1039
1139
|
for (const [i, path] of literals) {
|
|
1040
|
-
|
|
1140
|
+
const index = globsUnder[across(side[i]!)]!
|
|
1141
|
+
for (const j of index.get('') ?? []) pair(i, j)
|
|
1041
1142
|
for (let sep = path.indexOf('/'); sep !== -1; sep = path.indexOf('/', sep + 1)) {
|
|
1042
|
-
for (const j of
|
|
1143
|
+
for (const j of index.get(path.slice(0, sep)) ?? []) pair(i, j)
|
|
1043
1144
|
}
|
|
1044
1145
|
}
|
|
1045
1146
|
return [...pairs].sort((x, y) => x - y).map((p) => [Math.floor(p / n), p % n])
|
|
@@ -1236,7 +1337,11 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
|
|
|
1236
1337
|
if (projectReaders) {
|
|
1237
1338
|
for (const sides of byProject.values()) {
|
|
1238
1339
|
if (sides.length < 2) continue
|
|
1239
|
-
for (const [i, j] of overlapCandidates(
|
|
1340
|
+
for (const [i, j] of overlapCandidates(
|
|
1341
|
+
sides,
|
|
1342
|
+
(s) => s.globs,
|
|
1343
|
+
(s) => s.reads,
|
|
1344
|
+
)) {
|
|
1240
1345
|
readsOutputs(sides[i]!, sides[j]!, 'files')
|
|
1241
1346
|
}
|
|
1242
1347
|
}
|
|
@@ -1251,7 +1356,11 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
|
|
|
1251
1356
|
rooted.push({ node, globs, reads: false, rel })
|
|
1252
1357
|
}
|
|
1253
1358
|
}
|
|
1254
|
-
for (const [i, j] of overlapCandidates(
|
|
1359
|
+
for (const [i, j] of overlapCandidates(
|
|
1360
|
+
rooted,
|
|
1361
|
+
(s) => s.globs,
|
|
1362
|
+
(s) => s.reads,
|
|
1363
|
+
)) {
|
|
1255
1364
|
readsOutputs(rooted[i]!, rooted[j]!, 'workspaceFiles')
|
|
1256
1365
|
}
|
|
1257
1366
|
}
|
|
@@ -27,7 +27,10 @@ import { computeTaskHash, type ComputeHashArgs } from './task-hash.js'
|
|
|
27
27
|
|
|
28
28
|
/** What `taintTracker` answers per dispatch, and what it learns per finish. */
|
|
29
29
|
export interface TaintTracker {
|
|
30
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* Whether `node`, dispatched with these key `upstream` outcomes, builds on
|
|
32
|
+
* bytes nothing vouches for. Its order-only deps count too.
|
|
33
|
+
*/
|
|
31
34
|
judge: (node: TaskNode, upstream: TaskOutcome[]) => boolean
|
|
32
35
|
/** Every settled outcome, the scheduler's `onFinish`. */
|
|
33
36
|
settled: (outcome: TaskOutcome) => void
|
|
@@ -102,8 +105,14 @@ export function taintTracker(
|
|
|
102
105
|
}
|
|
103
106
|
return {
|
|
104
107
|
// A restore-tier task may run before its deps and see holes here; it
|
|
105
|
-
// never saves anyway (a hit restores), so a hole is not taint.
|
|
106
|
-
|
|
108
|
+
// never saves anyway (a hit restores), so a hole is not taint. The
|
|
109
|
+
// upstream is the key's, which drops order-only edges; their failure
|
|
110
|
+
// reaches the task through the dependency `--exclude-dependencies`
|
|
111
|
+
// took out, so they are read from the settled outcomes.
|
|
112
|
+
judge: (node, upstream) =>
|
|
113
|
+
seeds.has(node.id) ||
|
|
114
|
+
upstream.some(through) ||
|
|
115
|
+
node.orderOnly?.some((d) => through(outcomes.get(d))) === true,
|
|
107
116
|
settled: (outcome) => {
|
|
108
117
|
outcomes.set(outcome.node.id, outcome)
|
|
109
118
|
},
|
|
@@ -13,6 +13,15 @@ import { maskedCommand, secretMask } from '../util/index.js'
|
|
|
13
13
|
import type { Logger } from './logger.js'
|
|
14
14
|
import type { RunContext } from './summary.js'
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* A task no cache answered for: it declares no `cache`, or the run's policy
|
|
18
|
+
* read and wrote nothing. Its run is `no-cache`, never a miss, on every
|
|
19
|
+
* surface — `--dry`'s plan, the frame, the footer, `--report`, `--summarize`.
|
|
20
|
+
*/
|
|
21
|
+
export function ranNoCache(o: { node: TaskNode; cacheOff?: boolean }): boolean {
|
|
22
|
+
return o.node.config.cache === undefined || o.cacheOff === true
|
|
23
|
+
}
|
|
24
|
+
|
|
16
25
|
/** Payload of the `run:start` event — mirrors the Logger.runStart hook. */
|
|
17
26
|
export interface RunStartInfo {
|
|
18
27
|
total: number
|
|
@@ -373,7 +382,7 @@ export function projectOutcome(outcome: TaskOutcome): OutcomeView {
|
|
|
373
382
|
durationMs: outcome.durationMs,
|
|
374
383
|
}
|
|
375
384
|
if (isGroupTask(outcome.node)) view.isGroup = true
|
|
376
|
-
else if (outcome
|
|
385
|
+
else if (ranNoCache(outcome)) view.noCache = true
|
|
377
386
|
if (outcome.storedDurationMs !== undefined) view.storedDurationMs = outcome.storedDurationMs
|
|
378
387
|
if (outcome.storedCpuMs !== undefined) view.storedCpuMs = outcome.storedCpuMs
|
|
379
388
|
if (outcome.storedPeakRssBytes !== undefined) view.storedPeakRssBytes = outcome.storedPeakRssBytes
|
|
@@ -362,12 +362,17 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
362
362
|
args.cacheDir,
|
|
363
363
|
)
|
|
364
364
|
placeholders = sb.placeholders
|
|
365
|
+
// A wrap that refuses (a port the host holds) leaves the server unspawned
|
|
366
|
+
// and the placeholders vx made for it in the project for every later run.
|
|
365
367
|
const wrapped = await wrapSandboxedCommand({
|
|
366
368
|
command: plainCommand,
|
|
367
369
|
cwd: node.projectDir,
|
|
368
370
|
env,
|
|
369
371
|
...sb.sandbox,
|
|
370
372
|
server: true,
|
|
373
|
+
}).catch(async (err: unknown) => {
|
|
374
|
+
await sweepPlaceholders(placeholders)
|
|
375
|
+
throw err
|
|
371
376
|
})
|
|
372
377
|
command = wrapped.wrapped
|
|
373
378
|
bridgeTag = wrapped.tag
|
|
@@ -949,15 +954,21 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
949
954
|
const endReq = span('miss: build request')
|
|
950
955
|
const req = await buildRequest()
|
|
951
956
|
endReq()
|
|
952
|
-
// An executor that THROWS produces no captured output
|
|
953
|
-
//
|
|
954
|
-
//
|
|
955
|
-
//
|
|
956
|
-
// task failed, and a remote executor's failures are exactly the ones with
|
|
957
|
-
// no other trace. Rethrown unchanged — the scheduler still classifies it,
|
|
958
|
-
// and still prints it plainly for a UserError.
|
|
957
|
+
// An executor that THROWS produces no captured output. Rethrown: the
|
|
958
|
+
// scheduler classifies it and prints its one line into the task's own
|
|
959
|
+
// stream (run()'s onError), where the frame reads it; a copy written
|
|
960
|
+
// here too printed the reason twice.
|
|
959
961
|
const endExec = span('miss: execute')
|
|
960
|
-
|
|
962
|
+
// A plugin's execute may throw before returning or return a bare result:
|
|
963
|
+
// the throw is its rejection, the value its resolution, so both reach
|
|
964
|
+
// the naming catch and the cleanup in finally below.
|
|
965
|
+
let running: Promise<unknown>
|
|
966
|
+
try {
|
|
967
|
+
running = Promise.resolve(args.executor.execute(req))
|
|
968
|
+
} catch (thrown) {
|
|
969
|
+
running = Promise.reject(thrown)
|
|
970
|
+
}
|
|
971
|
+
let res = await boundAfterAbort(running, req.signal)
|
|
961
972
|
.then((r: unknown) => {
|
|
962
973
|
assertExecuteResult(args.executor.name, node.id, r)
|
|
963
974
|
return r
|
|
@@ -981,15 +992,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
981
992
|
}
|
|
982
993
|
const err = nameExecutorFailure(args.executor, raw)
|
|
983
994
|
// A remote executor's message carries the server's own text, which
|
|
984
|
-
// may echo the env it was sent: masked
|
|
985
|
-
//
|
|
995
|
+
// may echo the env it was sent: masked on the error the scheduler
|
|
996
|
+
// prints with its cause (L-39).
|
|
986
997
|
if (secrets !== null) {
|
|
987
998
|
for (const e of [err, err instanceof Error ? err.cause : undefined])
|
|
988
999
|
if (e instanceof Error) e.message = secrets.mask(e.message)
|
|
989
1000
|
}
|
|
990
|
-
const message =
|
|
991
|
-
err instanceof Error ? err.message : (secrets?.mask(String(err)) ?? String(err))
|
|
992
|
-
log.taskStderr(node, `${message}\n`)
|
|
993
1001
|
await sweepPlaceholders(placeholders)
|
|
994
1002
|
throw err
|
|
995
1003
|
})
|
|
@@ -1141,6 +1149,21 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1141
1149
|
args.executor.remote === true ? 'none' : undeclaredWriteReach(node, args.workspaceRoot)
|
|
1142
1150
|
const writesFingerprint =
|
|
1143
1151
|
args.executor.remote !== true && mayWriteFingerprint(node, args.workspaceRoot)
|
|
1152
|
+
// A stop that landed during the awaits above (the key, the probe, the
|
|
1153
|
+
// input description): the first attempt would wipe the last build and
|
|
1154
|
+
// hand the executor a request after the run had stopped. Through a call,
|
|
1155
|
+
// so the loop's own reads of the stop are not narrowed to false.
|
|
1156
|
+
if (isAborted(args.stopSignal)) {
|
|
1157
|
+
return {
|
|
1158
|
+
node,
|
|
1159
|
+
status: 'aborted',
|
|
1160
|
+
exitCode: signalExitCode(forwardedSignal(args.stopSignal?.reason)),
|
|
1161
|
+
durationMs: 0,
|
|
1162
|
+
hash,
|
|
1163
|
+
wallclockStartNs,
|
|
1164
|
+
wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1144
1167
|
for (;;) {
|
|
1145
1168
|
attempt++
|
|
1146
1169
|
const a = await runAttempt()
|
|
@@ -29,6 +29,8 @@ export type FailureMode = 'stable' | 'flaky-recoverable' | 'flaky-fatal'
|
|
|
29
29
|
function keyOutcomesSql(source: string, where = ''): string {
|
|
30
30
|
return `SELECT project, task, hash,
|
|
31
31
|
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failures,
|
|
32
|
+
SUM(CASE WHEN status = 'success' AND attempts > 1 THEN attempts - 1 ELSE 0 END)
|
|
33
|
+
AS retried_failures,
|
|
32
34
|
SUM(CASE WHEN status = 'success' OR status IN ${HIT_STATUSES} OR cache_hit = 1
|
|
33
35
|
THEN 1 ELSE 0 END) AS passes
|
|
34
36
|
FROM ${source}
|
|
@@ -186,13 +188,17 @@ export interface FlakyTask {
|
|
|
186
188
|
/**
|
|
187
189
|
* Every task with a mixed-outcome key anywhere in the retained history
|
|
188
190
|
* (30 days, `RunHistory.pruneOlderThan`), most failures first: the
|
|
189
|
-
* doctor's list. A
|
|
191
|
+
* doctor's list. A pass that took a retry mixes its key on its own: it is
|
|
192
|
+
* one `success` row whose failed attempts count as failures (`vx info`
|
|
193
|
+
* said `none` over one). A full scan of `runs`, which an inspection verb
|
|
194
|
+
* affords.
|
|
190
195
|
*/
|
|
191
196
|
export function flakyTasks(db: Database): FlakyTask[] {
|
|
192
197
|
const rows = db
|
|
193
198
|
.query(
|
|
194
199
|
`SELECT project, task, COUNT(*) AS keys, SUM(passes) AS passes, SUM(failures) AS failures
|
|
195
|
-
FROM (
|
|
200
|
+
FROM (SELECT project, task, passes, failures + retried_failures AS failures
|
|
201
|
+
FROM (${keyOutcomesSql('runs')}))
|
|
196
202
|
WHERE failures > 0 AND passes > 0
|
|
197
203
|
GROUP BY project, task
|
|
198
204
|
ORDER BY failures DESC, passes DESC, project, task`,
|
|
@@ -30,7 +30,7 @@ import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
|
30
30
|
import { maskedCommand } from '../util/index.js'
|
|
31
31
|
import { paint, type ColorSupport } from './colors.js'
|
|
32
32
|
import { formatDuration, neverStarted } from './summary.js'
|
|
33
|
-
import { outcomeLabel, skippedReason } from './events.js'
|
|
33
|
+
import { outcomeLabel, ranNoCache, skippedReason } from './events.js'
|
|
34
34
|
|
|
35
35
|
const NO_COLOR: ColorSupport = { enabled: false }
|
|
36
36
|
|
|
@@ -274,8 +274,8 @@ function cacheOf(o: TaskOutcome): { word: string; color: string } {
|
|
|
274
274
|
switch (o.status) {
|
|
275
275
|
case 'success':
|
|
276
276
|
case 'failed':
|
|
277
|
-
// A task
|
|
278
|
-
return { word: o
|
|
277
|
+
// A task no cache answered for never consulted one: not a miss.
|
|
278
|
+
return { word: ranNoCache(o) ? 'no-cache' : 'miss', color: '' } // dim
|
|
279
279
|
case 'cache-hit':
|
|
280
280
|
return o.restored === false
|
|
281
281
|
? { word: 'fresh', color: SUCCESS }
|
|
@@ -23,7 +23,7 @@ import { formatDuration, formatSummarySection, neverStarted, type RunContext } f
|
|
|
23
23
|
import { isGroupTask } from '../graph/index.js'
|
|
24
24
|
import { appendTail, createTail, resetTail, tailText, type Tail } from '../util/index.js'
|
|
25
25
|
import { isCacheHit } from './telemetry.js'
|
|
26
|
-
import { failedLabel, outcomeWord } from './events.js'
|
|
26
|
+
import { failedLabel, outcomeWord, ranNoCache } from './events.js'
|
|
27
27
|
|
|
28
28
|
export interface Logger {
|
|
29
29
|
/** Header / footer / status text. Written verbatim, one trailing \n added. */
|
|
@@ -139,8 +139,7 @@ function ghaFence(body: string, token: string): string {
|
|
|
139
139
|
return `::stop-commands::${token}\n${body}::${token}::\n`
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
const cacheWordOf = (
|
|
143
|
-
n.config.cache === undefined ? 'no-cache' : 'miss'
|
|
142
|
+
const cacheWordOf = (o: TaskOutcome): 'miss' | 'no-cache' => (ranNoCache(o) ? 'no-cache' : 'miss')
|
|
144
143
|
|
|
145
144
|
export function resolveOutputView(
|
|
146
145
|
options: {
|
|
@@ -440,7 +439,7 @@ export function defaultLogger(
|
|
|
440
439
|
(view.mode === 'focused' && isPrimary(node) && !isGroupTask(node) && requestedCount <= 1)
|
|
441
440
|
|
|
442
441
|
const failureRow = (node: TaskNode, outcome: TaskOutcome): string =>
|
|
443
|
-
formatFailureLine(node.id, outcome.durationMs, colors, cacheWordOf(
|
|
442
|
+
formatFailureLine(node.id, outcome.durationMs, colors, cacheWordOf(outcome)) +
|
|
444
443
|
flakyNote(outcome, colors)
|
|
445
444
|
|
|
446
445
|
return {
|
|
@@ -453,6 +452,12 @@ export function defaultLogger(
|
|
|
453
452
|
// A kept server's held partial line is older than this line: held
|
|
454
453
|
// past it, a server's last words printed below its own exit notice.
|
|
455
454
|
flushKept()
|
|
455
|
+
// A live-streamed server keeps writing after its frame closes, so a
|
|
456
|
+
// summary row or "exited with code" can follow its partial line.
|
|
457
|
+
if (streamMidLine) {
|
|
458
|
+
writer.write('\n')
|
|
459
|
+
streamMidLine = false
|
|
460
|
+
}
|
|
456
461
|
writer.write(`${line}\n`)
|
|
457
462
|
},
|
|
458
463
|
runStart(info) {
|
|
@@ -654,7 +659,7 @@ export function defaultLogger(
|
|
|
654
659
|
done++
|
|
655
660
|
if (outcome.status === 'failed') {
|
|
656
661
|
failed++
|
|
657
|
-
if (
|
|
662
|
+
if (ranNoCache(outcome)) noCache++
|
|
658
663
|
} else if (node.config.exec?.persistent !== undefined && outcome.status === 'success') {
|
|
659
664
|
// A persistent task's outcome arrives at READY; the child
|
|
660
665
|
// keeps running until the orchestrator SIGTERMs it at run
|
|
@@ -678,7 +683,7 @@ export function defaultLogger(
|
|
|
678
683
|
switch (outcome.status) {
|
|
679
684
|
case 'success':
|
|
680
685
|
succeeded++
|
|
681
|
-
if (
|
|
686
|
+
if (ranNoCache(outcome)) noCache++
|
|
682
687
|
break
|
|
683
688
|
case 'cache-hit':
|
|
684
689
|
if (outcome.restored === false) upToDate++
|
|
@@ -407,11 +407,18 @@ function unchangedKeyNote(
|
|
|
407
407
|
at: number
|
|
408
408
|
} | null
|
|
409
409
|
)?.at
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
// (admission.ts), which runs a task downstream of
|
|
413
|
-
// saves it. Blaming `--no-cache` / `--force` named a
|
|
414
|
-
|
|
410
|
+
// The previous run executed the task and succeeded with the cache
|
|
411
|
+
// writable beside a failure, and no entry older than this run holds the
|
|
412
|
+
// key: the continue-taint (admission.ts), which runs a task downstream of
|
|
413
|
+
// a failure and never saves it. Blaming `--no-cache` / `--force` named a
|
|
414
|
+
// flag nobody passed, and a prune when this run saved the key named an
|
|
415
|
+
// eviction that never happened.
|
|
416
|
+
if (
|
|
417
|
+
(createdAt === undefined || createdAt >= this_.startedAt) &&
|
|
418
|
+
this_.status === 'success' &&
|
|
419
|
+
prev.status === 'success' &&
|
|
420
|
+
prev.cacheHit === 0
|
|
421
|
+
) {
|
|
415
422
|
const failedWithWrites = (id: string | null): number => {
|
|
416
423
|
if (id === null) return 0
|
|
417
424
|
const row = db
|
|
@@ -420,9 +427,13 @@ function unchangedKeyNote(
|
|
|
420
427
|
const writes = row?.p.split(',').some((axis) => axis === 'lW' || axis === 'rW') === true
|
|
421
428
|
return writes ? (row?.f ?? 0) : 0
|
|
422
429
|
}
|
|
423
|
-
const
|
|
424
|
-
if (
|
|
425
|
-
|
|
430
|
+
const before = failedWithWrites(prev.runId)
|
|
431
|
+
if (before > 0) {
|
|
432
|
+
const now = failedWithWrites(runId)
|
|
433
|
+
if (createdAt === undefined && now > 0) {
|
|
434
|
+
return `cache key unchanged — neither run saved it: each ran beside a failed task (${now} failed in this one), and a task run past a failed dependency (--continue) is never cached`
|
|
435
|
+
}
|
|
436
|
+
return `cache key unchanged — the previous run on this key ran beside a failed task (${before} failed in it) and was not saved: a task run past a failed dependency (--continue) is never cached, so there was nothing to hit`
|
|
426
437
|
}
|
|
427
438
|
}
|
|
428
439
|
if (createdAt !== undefined && createdAt >= this_.startedAt) {
|
|
@@ -70,8 +70,8 @@ export interface SaveMissArgs {
|
|
|
70
70
|
nestedProjectDirs: string[]
|
|
71
71
|
gitFilesCache?: GitFilesCache | undefined
|
|
72
72
|
/** Declared `cache.outputs.files` / `.workspaceFiles`. */
|
|
73
|
-
outputs: string[]
|
|
74
|
-
wsOutputs: string[]
|
|
73
|
+
outputs: readonly string[]
|
|
74
|
+
wsOutputs: readonly string[]
|
|
75
75
|
/**
|
|
76
76
|
* An ADDITIVE task's own set (item 588): the files its run added or
|
|
77
77
|
* changed under its declared outputs, resolved by execute-task against
|
|
@@ -171,6 +171,13 @@ export interface RunOptions {
|
|
|
171
171
|
* else is treated as the literal file path (cwd-relative).
|
|
172
172
|
*/
|
|
173
173
|
summarize?: string
|
|
174
|
+
/**
|
|
175
|
+
* Text printed just above the run's footer, given the outcomes the
|
|
176
|
+
* footer counts, after the `summaryTable`: nothing prints below the
|
|
177
|
+
* footer (owner), so a caller's end-of-run report (`--report`) rides
|
|
178
|
+
* here. Not called on a refused run.
|
|
179
|
+
*/
|
|
180
|
+
beforeFooter?: (outcomes: readonly TaskOutcome[], ok: boolean) => string
|
|
174
181
|
/**
|
|
175
182
|
* If set, write a Chrome-trace JSON profile of the run's wallclock
|
|
176
183
|
* spans. Path is cwd-relative. Default `profile.json` is selected
|
package/src/orchestrator/plan.ts
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
// side-effect-free pure probes.
|
|
13
13
|
|
|
14
14
|
import type { CacheLayer, CachePolicy, GitFilesCache } from '../cache/index.js'
|
|
15
|
-
import { FULL_CACHE_POLICY } from '../cache/index.js'
|
|
15
|
+
import { cachesNothing, FULL_CACHE_POLICY } from '../cache/index.js'
|
|
16
|
+
import { ranNoCache } from './events.js'
|
|
16
17
|
import {
|
|
17
18
|
idleServers,
|
|
18
19
|
isGroupTask,
|
|
@@ -169,9 +170,8 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
|
|
|
169
170
|
// policy-aware cache layer, which itself respects read gating.
|
|
170
171
|
const policy = args.cachePolicy ?? FULL_CACHE_POLICY
|
|
171
172
|
const reads = policy.localRead || policy.remoteRead
|
|
172
|
-
const writes = policy.localWrite || policy.remoteWrite
|
|
173
173
|
let status: CacheStatus
|
|
174
|
-
if (node
|
|
174
|
+
if (ranNoCache({ node, cacheOff: cachesNothing(policy) })) {
|
|
175
175
|
status = 'no-cache'
|
|
176
176
|
} else if (!reads) {
|
|
177
177
|
status = 'miss'
|