@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.
Files changed (50) hide show
  1. package/README.md +1 -1
  2. package/package.json +5 -5
  3. package/src/cache/cache.ts +139 -84
  4. package/src/cache/chained-cache.ts +25 -7
  5. package/src/cache/index.ts +1 -0
  6. package/src/cache/inputs.ts +12 -12
  7. package/src/cache/layer.ts +7 -5
  8. package/src/cache/policy.ts +5 -0
  9. package/src/cache/zstd.ts +10 -4
  10. package/src/cli/cache.ts +10 -4
  11. package/src/cli/foreign-flags.ts +1 -1
  12. package/src/cli/last.ts +4 -1
  13. package/src/cli/run.ts +31 -15
  14. package/src/cli/watch-filter.ts +63 -12
  15. package/src/cli/watch-judge.ts +15 -8
  16. package/src/cli/watch.ts +4 -4
  17. package/src/config.ts +21 -21
  18. package/src/exec/proc-sample.ts +1 -1
  19. package/src/exec/sandbox-paths.ts +1 -1
  20. package/src/exec/sandbox-runtime.ts +116 -40
  21. package/src/exec/sandbox-violations.ts +58 -11
  22. package/src/graph/scheduler.ts +28 -12
  23. package/src/graph/task-graph.ts +134 -25
  24. package/src/orchestrator/admission.ts +12 -3
  25. package/src/orchestrator/events.ts +10 -1
  26. package/src/orchestrator/execute-task.ts +36 -13
  27. package/src/orchestrator/failure-mode.ts +8 -2
  28. package/src/orchestrator/framed-output.ts +3 -3
  29. package/src/orchestrator/logger.ts +11 -6
  30. package/src/orchestrator/metrics.ts +19 -8
  31. package/src/orchestrator/miss-save.ts +2 -2
  32. package/src/orchestrator/options.ts +7 -0
  33. package/src/orchestrator/plan.ts +3 -3
  34. package/src/orchestrator/plugin.ts +37 -16
  35. package/src/orchestrator/prepare.ts +13 -4
  36. package/src/orchestrator/run-artifacts.ts +2 -1
  37. package/src/orchestrator/run.ts +20 -4
  38. package/src/orchestrator/sandbox-request.ts +65 -9
  39. package/src/orchestrator/save-lane.ts +5 -5
  40. package/src/orchestrator/summary.ts +2 -4
  41. package/src/orchestrator/telemetry.ts +16 -7
  42. package/src/util/paths.ts +23 -3
  43. package/src/workspace/config-imports.ts +7 -5
  44. package/src/workspace/config-schema.ts +26 -3
  45. package/src/workspace/filter.ts +19 -0
  46. package/src/workspace/migrate-scripts.ts +4 -2
  47. package/src/workspace/migration.ts +1 -1
  48. package/src/workspace/project-loader.ts +4 -2
  49. package/src/workspace/repo-id.ts +42 -4
  50. package/src/workspace/workspace.ts +17 -10
@@ -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 (declaresTask(projects, name, spec)) push(name, spec)
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 || candidates.some((name) => declaresTask(projects, name, spec))
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 (visit(frame, target, spec.task, false)) held = true
464
- else frontier.push(...packageGraph.directDeps(target))
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 globs = new Map<string, number[]>()
984
- const globsUnder = new Map<string, number[]>()
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 = new Map<string, number[]>()
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, tree, i)
1099
+ file(globs[s]!, tree, i)
1003
1100
  const dir = wholeSubtreePrefixes([tree])?.[0]
1004
- if (dir !== undefined) file(subtrees, dir, i)
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, head.slice(0, Math.max(0, head.lastIndexOf('/'))), i)
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.values()) {
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 < list.length; y++) pair(list[x]!, list[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 = new Map<string, number[]>()
1024
- for (const [i, prefix] of prefixes) file(byPrefix, rootless(prefix), i)
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 byPrefix.get(prefix) ?? []) pair(i, j)
1028
- if (prefix !== '') for (const j of byPrefix.get('') ?? []) pair(i, j)
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 byPrefix.get(prefix.slice(0, sep)) ?? []) pair(i, j)
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
- for (const j of subtrees.get(prefix) ?? []) pair(i, j)
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 subtrees.get(prefix.slice(0, sep)) ?? []) pair(i, j)
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
- for (const j of globsUnder.get('') ?? []) pair(i, j)
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 globsUnder.get(path.slice(0, sep)) ?? []) pair(i, j)
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(sides, (s) => s.globs)) {
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(rooted, (s) => s.globs)) {
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
- /** Whether `node`, dispatched with these `upstream` outcomes, builds on bytes nothing vouches for. */
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
- judge: (node, upstream) => seeds.has(node.id) || upstream.some(through),
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.node.config.cache === undefined) view.noCache = true
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, so the task's
953
- // frame would print the command and nothing else while the reason went
954
- // straight to stderr and scrolled away in a broad run. Put it in the
955
- // task's own stream first: the frame is where a reader looks for why a
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
- let res = await boundAfterAbort(args.executor.execute(req), req.signal)
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 here, where it is printed,
985
- // and on the error the scheduler prints with its cause (L-39).
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 full scan of `runs`, which an inspection verb affords.
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 (${keyOutcomesSql('runs')})
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 with no `cache` block never consulted the cache: not a miss.
278
- return { word: o.node.config.cache === undefined ? 'no-cache' : 'miss', color: '' } // dim
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 = (n: TaskNode): 'miss' | 'no-cache' =>
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(node)) +
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 (node.config.cache === undefined) noCache++
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 (node.config.cache === undefined) noCache++
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
- // Both runs executed the task and succeeded with the cache writable, no
411
- // entry holds the key, and each ran beside a failure: the continue-taint
412
- // (admission.ts), which runs a task downstream of a failure and never
413
- // saves it. Blaming `--no-cache` / `--force` named a flag nobody passed.
414
- if (createdAt === undefined && this_.status === 'success' && prev.status === 'success') {
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 now = failedWithWrites(runId)
424
- if (prev.cacheHit === 0 && now > 0 && failedWithWrites(prev.runId) > 0) {
425
- 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`
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
@@ -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.config.cache === undefined || (!reads && !writes)) {
174
+ if (ranNoCache({ node, cacheOff: cachesNothing(policy) })) {
175
175
  status = 'no-cache'
176
176
  } else if (!reads) {
177
177
  status = 'miss'