@vzn/vx 0.0.594 → 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 (80) hide show
  1. package/README.md +1 -1
  2. package/package.json +5 -5
  3. package/src/bin.ts +26 -11
  4. package/src/cache/archive.ts +2 -2
  5. package/src/cache/cache.ts +235 -174
  6. package/src/cache/chained-cache.ts +25 -7
  7. package/src/cache/config-evals.ts +19 -4
  8. package/src/cache/git-inputs.ts +19 -8
  9. package/src/cache/index.ts +1 -0
  10. package/src/cache/inputs.ts +122 -49
  11. package/src/cache/layer.ts +7 -5
  12. package/src/cache/layered-cache.ts +25 -2
  13. package/src/cache/policy.ts +5 -0
  14. package/src/cache/tar-stream.ts +1 -1
  15. package/src/cache/zstd.ts +10 -4
  16. package/src/cli/cache.ts +11 -15
  17. package/src/cli/completions.ts +42 -6
  18. package/src/cli/foreign-flags.ts +26 -5
  19. package/src/cli/help.ts +5 -3
  20. package/src/cli/init.ts +1 -1
  21. package/src/cli/last.ts +4 -1
  22. package/src/cli/plan-format.ts +9 -0
  23. package/src/cli/plugin-templates.ts +2 -2
  24. package/src/cli/run.ts +60 -42
  25. package/src/cli/select.ts +30 -3
  26. package/src/cli/show.ts +42 -3
  27. package/src/cli/watch-filter.ts +77 -13
  28. package/src/cli/watch-fs.ts +15 -7
  29. package/src/cli/watch-judge.ts +16 -9
  30. package/src/cli/watch.ts +22 -15
  31. package/src/config.ts +26 -26
  32. package/src/exec/env.ts +5 -0
  33. package/src/exec/index.ts +1 -0
  34. package/src/exec/proc-sample.ts +6 -2
  35. package/src/exec/runner.ts +98 -40
  36. package/src/exec/sandbox-paths.ts +12 -6
  37. package/src/exec/sandbox-runtime.ts +136 -44
  38. package/src/exec/sandbox-violations.ts +58 -11
  39. package/src/graph/index.ts +1 -0
  40. package/src/graph/scheduler.ts +29 -13
  41. package/src/graph/task-graph.ts +134 -25
  42. package/src/index.ts +6 -0
  43. package/src/orchestrator/admission.ts +12 -3
  44. package/src/orchestrator/events.ts +10 -1
  45. package/src/orchestrator/execute-task.ts +65 -20
  46. package/src/orchestrator/failure-mode.ts +8 -2
  47. package/src/orchestrator/framed-output.ts +3 -3
  48. package/src/orchestrator/hit-restore.ts +6 -3
  49. package/src/orchestrator/local-shortcircuit.ts +4 -1
  50. package/src/orchestrator/lockfile-claim.ts +43 -46
  51. package/src/orchestrator/logger.ts +11 -6
  52. package/src/orchestrator/metrics.ts +19 -8
  53. package/src/orchestrator/miss-save.ts +2 -2
  54. package/src/orchestrator/options.ts +7 -0
  55. package/src/orchestrator/plan.ts +28 -4
  56. package/src/orchestrator/plugin-host.ts +25 -3
  57. package/src/orchestrator/plugin.ts +39 -17
  58. package/src/orchestrator/prepare.ts +13 -4
  59. package/src/orchestrator/run-artifacts.ts +2 -1
  60. package/src/orchestrator/run.ts +72 -12
  61. package/src/orchestrator/sandbox-request.ts +65 -9
  62. package/src/orchestrator/save-lane.ts +5 -5
  63. package/src/orchestrator/signals.ts +5 -2
  64. package/src/orchestrator/summary.ts +16 -5
  65. package/src/orchestrator/telemetry.ts +16 -7
  66. package/src/util/index.ts +7 -1
  67. package/src/util/paths.ts +23 -3
  68. package/src/util/secret-mask.ts +36 -0
  69. package/src/util/settle.ts +15 -0
  70. package/src/workspace/affected.ts +30 -8
  71. package/src/workspace/config-cache.ts +25 -4
  72. package/src/workspace/config-imports.ts +8 -6
  73. package/src/workspace/config-schema.ts +74 -9
  74. package/src/workspace/filter.ts +19 -0
  75. package/src/workspace/migrate-scripts.ts +5 -3
  76. package/src/workspace/migration.ts +1 -1
  77. package/src/workspace/package-graph.ts +15 -3
  78. package/src/workspace/project-loader.ts +76 -2
  79. package/src/workspace/repo-id.ts +42 -4
  80. package/src/workspace/workspace.ts +98 -20
@@ -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
  }
package/src/index.ts CHANGED
@@ -179,6 +179,12 @@ export type {
179
179
  // A remote executor gives a task back to the local floor by rejecting with
180
180
  // this (B-100): core runs it here and says why once.
181
181
  export { executorFallback } from './exec/index.js'
182
+ // How the local executor puts the args after `--` on a command, for an
183
+ // executor that runs `ExecuteRequest.command` itself: `@vzn/vx-reapi` and
184
+ // the `vx init --plugin executor` example each joined their own, and a
185
+ // trailing comment or a template literal's closing newline lost the args
186
+ // there while the local floor kept them (X-112).
187
+ export { withForwardArgs } from './exec/index.js'
182
188
  export type {
183
189
  ExecuteRequest,
184
190
  ExecuteResult,
@@ -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
@@ -38,6 +38,7 @@ import {
38
38
  type TaskInputs,
39
39
  PersistentReadyError,
40
40
  sandboxReads,
41
+ maskCaptured,
41
42
  } from '../exec/index.js'
42
43
  import { isGroupTask, RestoreDemoted, type TaskNode, type TaskOutcome } from '../graph/index.js'
43
44
  import {
@@ -361,12 +362,17 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
361
362
  args.cacheDir,
362
363
  )
363
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.
364
367
  const wrapped = await wrapSandboxedCommand({
365
368
  command: plainCommand,
366
369
  cwd: node.projectDir,
367
370
  env,
368
371
  ...sb.sandbox,
369
372
  server: true,
373
+ }).catch(async (err: unknown) => {
374
+ await sweepPlaceholders(placeholders)
375
+ throw err
370
376
  })
371
377
  command = wrapped.wrapped
372
378
  bridgeTag = wrapped.tag
@@ -534,6 +540,20 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
534
540
  }
535
541
  }
536
542
 
543
+ // Ready only once the stop had landed: a trap that prints the marker on
544
+ // the way down, or a race with the signal. The stop is killing it (it
545
+ // is in `liveChildren`), and it served nobody, as a one-shot that exits
546
+ // 0 on the stop is aborted (item 962).
547
+ if (isAborted(args.stopSignal)) {
548
+ return {
549
+ node,
550
+ status: 'aborted',
551
+ exitCode: signalExitCode(forwardedSignal(args.stopSignal!.reason)),
552
+ durationMs: spawn.readyMs(),
553
+ wallclockStartNs,
554
+ wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
555
+ }
556
+ }
537
557
  args.persistentRegistry?.set(node.id, spawn.child)
538
558
  forgetUndeclaredWrites(args, undeclaredWriteReach(node, args.workspaceRoot))
539
559
  if (mayWriteFingerprint(node, args.workspaceRoot)) args.fingerprintWatch?.wrote()
@@ -914,7 +934,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
914
934
  // re-create would stay in a same-project consumer's input set, keeping
915
935
  // that consumer's key unchanged while the file is gone from disk.
916
936
  const endClean = span('miss: clean outputs')
917
- const cleanedRels = await cleanOutputs({ ...cleanArgs, keepGlobRoots: true })
937
+ const cleanedRels = await cleanOutputs(cleanArgs)
918
938
  endClean()
919
939
  args.gitFilesCache?.noteClean(node.id, node.projectDir, cleanedRels)
920
940
  args.gitFilesCache?.markOutputsChanged(node.projectDir, cleanedRels)
@@ -934,15 +954,21 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
934
954
  const endReq = span('miss: build request')
935
955
  const req = await buildRequest()
936
956
  endReq()
937
- // An executor that THROWS produces no captured output, so the task's
938
- // frame would print the command and nothing else while the reason went
939
- // straight to stderr and scrolled away in a broad run. Put it in the
940
- // task's own stream first: the frame is where a reader looks for why a
941
- // task failed, and a remote executor's failures are exactly the ones with
942
- // no other trace. Rethrown unchanged — the scheduler still classifies it,
943
- // 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.
944
961
  const endExec = span('miss: execute')
945
- 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)
946
972
  .then((r: unknown) => {
947
973
  assertExecuteResult(args.executor.name, node.id, r)
948
974
  return r
@@ -966,15 +992,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
966
992
  }
967
993
  const err = nameExecutorFailure(args.executor, raw)
968
994
  // A remote executor's message carries the server's own text, which
969
- // may echo the env it was sent: masked here, where it is printed,
970
- // 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).
971
997
  if (secrets !== null) {
972
998
  for (const e of [err, err instanceof Error ? err.cause : undefined])
973
999
  if (e instanceof Error) e.message = secrets.mask(e.message)
974
1000
  }
975
- const message =
976
- err instanceof Error ? err.message : (secrets?.mask(String(err)) ?? String(err))
977
- log.taskStderr(node, `${message}\n`)
978
1001
  await sweepPlaceholders(placeholders)
979
1002
  throw err
980
1003
  })
@@ -988,7 +1011,11 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
988
1011
  })
989
1012
  endExec()
990
1013
  if (secrets !== null)
991
- res = { ...res, stdout: secrets.mask(res.stdout), stderr: secrets.mask(res.stderr) }
1014
+ res = {
1015
+ ...res,
1016
+ stdout: maskCaptured(res.stdout, secrets),
1017
+ stderr: maskCaptured(res.stderr, secrets),
1018
+ }
992
1019
  // An executor that stopped on the timeout's abort exits non-zero; say
993
1020
  // why, so the frame, the retry line and `timedOut` read as a timeout.
994
1021
  if (timeoutFired && res.exitCode !== 0 && res.timedOut !== true) {
@@ -1051,8 +1078,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1051
1078
  // 258) — and an exit above 128 is a signal's number and nothing
1052
1079
  // about what sent it (259). One frame line names the rule. A spawn that
1053
1080
  // threw ran no shell: its 127 is vx's, and the line would send the
1054
- // reader after a command that exists (A-41).
1055
- if (!res.timedOut && res.spawnFailed !== true) {
1081
+ // reader after a command that exists (A-41). A run's stop killed it
1082
+ // itself: the SIGKILL past the grace read as the OOM killer's.
1083
+ if (!res.timedOut && res.spawnFailed !== true && args.stopSignal?.aborted !== true) {
1056
1084
  const verdict = shellVerdict({
1057
1085
  code,
1058
1086
  command: step.command,
@@ -1121,6 +1149,21 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1121
1149
  args.executor.remote === true ? 'none' : undeclaredWriteReach(node, args.workspaceRoot)
1122
1150
  const writesFingerprint =
1123
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
+ }
1124
1167
  for (;;) {
1125
1168
  attempt++
1126
1169
  const a = await runAttempt()
@@ -1203,7 +1246,6 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1203
1246
  },
1204
1247
  }
1205
1248
  : {}),
1206
- signal: requestSignal(),
1207
1249
  ...(effectiveTimeout !== undefined ? { timeoutMs: effectiveTimeout } : {}),
1208
1250
  ...(inputs !== undefined ? { inputs } : {}),
1209
1251
  ...(cfgCacheable ? { cacheKey: hash } : {}),
@@ -1215,7 +1257,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1215
1257
  outputs: { files: outputs, workspaceFiles: wsOutputs },
1216
1258
  ...(args.terminal === true ? { terminal: true as const } : {}),
1217
1259
  }
1218
- if (!userSandbox) return base
1260
+ // The signal last: it arms `exec.timeout`, and the sandbox's arming
1261
+ // below is vx's work, not the task's. Armed first, a 60 ms timeout
1262
+ // expired before the spawn and failed `echo` as timed out, unrun.
1263
+ if (!userSandbox) return { ...base, signal: requestSignal() }
1219
1264
  await args.armSandbox?.()
1220
1265
  const sb = await sandboxRequestFor(
1221
1266
  node,
@@ -1227,7 +1272,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1227
1272
  )
1228
1273
  placeholders = sb.placeholders
1229
1274
  withheld = sb.withheld
1230
- return { ...base, sandbox: sb.sandbox }
1275
+ return { ...base, sandbox: sb.sandbox, signal: requestSignal() }
1231
1276
  }
1232
1277
 
1233
1278
  const wallclockEndNs = process.hrtime.bigint() - args.runStartHrTimeNs
@@ -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 }
@@ -8,7 +8,6 @@
8
8
  // which bytes a green run replays. Moved out of execute-task.ts
9
9
  // 2026-09-10 as pure code motion, the mirror of miss-save.ts.
10
10
 
11
- import path from 'node:path'
12
11
  import type { CacheConfig } from '../config.js'
13
12
  import {
14
13
  type CacheEntry,
@@ -224,11 +223,15 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
224
223
  let cleanedWsRels: string[] = []
225
224
  if (additive) {
226
225
  cleanedRels = hit.outputFiles.filter((p) => !p.startsWith(WORKSPACE_OUTPUT_PREFIX))
227
- await cleanOutputPaths({ projectDir: node.projectDir, rels: cleanedRels })
226
+ await cleanOutputPaths({ projectDir: node.projectDir, rels: cleanedRels, outputs })
228
227
  cleanedWsRels = hit.outputFiles
229
228
  .filter((p) => p.startsWith(WORKSPACE_OUTPUT_PREFIX))
230
229
  .map((p) => p.slice(WORKSPACE_OUTPUT_PREFIX.length))
231
- await cleanOutputPaths({ projectDir: args.workspaceRoot, rels: cleanedWsRels })
230
+ await cleanOutputPaths({
231
+ projectDir: args.workspaceRoot,
232
+ rels: cleanedWsRels,
233
+ outputs: wsOutputs,
234
+ })
232
235
  } else {
233
236
  if (outputs.length > 0) cleanedRels = await cleanOutputs(cleanArgs)
234
237
  if (wsOutputs.length > 0) cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
@@ -194,7 +194,10 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
194
194
  * task's key, which is preliminary, so it cannot restore early either.
195
195
  * Cost: one pass over the nodes, no filesystem.
196
196
  */
197
- function restoreTierExclusions(nodes: Map<string, TaskNode>, workspaceRoot: string): Set<string> {
197
+ export function restoreTierExclusions(
198
+ nodes: Map<string, TaskNode>,
199
+ workspaceRoot: string,
200
+ ): Set<string> {
198
201
  const prefixes: string[] = []
199
202
  let everything = false
200
203
  for (const node of nodes.values()) {