@vzn/vx 0.0.639 → 0.0.641

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 (88) hide show
  1. package/README.md +1 -1
  2. package/docs/caching.md +158 -106
  3. package/docs/cli.md +80 -45
  4. package/docs/execution.md +11 -10
  5. package/docs/features.md +9 -8
  6. package/docs/patterns.md +1 -1
  7. package/docs/schema.md +252 -190
  8. package/docs/security.md +5 -4
  9. package/launcher.sh +9 -2
  10. package/package.json +5 -5
  11. package/schemas/show.json +14 -14
  12. package/skills/vx/SKILL.md +4 -3
  13. package/src/bin.ts +8 -6
  14. package/src/cache/archive.ts +248 -41
  15. package/src/cache/cache.ts +46 -19
  16. package/src/cache/file-hashes.ts +7 -12
  17. package/src/cache/fs-clock.ts +62 -0
  18. package/src/cache/git-inputs.ts +2 -2
  19. package/src/cache/index.ts +6 -0
  20. package/src/cache/inputs.ts +228 -111
  21. package/src/cache/key-fold.ts +23 -3
  22. package/src/cache/layer.ts +15 -8
  23. package/src/cache/layered-cache.ts +8 -7
  24. package/src/cache/linked-inputs.ts +87 -0
  25. package/src/cache/scoped-cache.ts +8 -2
  26. package/src/cli/foreign-flags.ts +1 -1
  27. package/src/cli/help.ts +7 -1
  28. package/src/cli/init.ts +23 -11
  29. package/src/cli/last.ts +24 -1
  30. package/src/cli/plugin-templates.ts +2 -2
  31. package/src/cli/select.ts +16 -6
  32. package/src/cli/show.ts +9 -6
  33. package/src/cli/upgrade.ts +132 -21
  34. package/src/cli/watch-filter.ts +23 -12
  35. package/src/cli/watch-fs.ts +2 -1
  36. package/src/cli/watch-judge.ts +49 -5
  37. package/src/cli/watch-set.ts +18 -8
  38. package/src/cli/watch.ts +74 -2
  39. package/src/cli/why.ts +5 -3
  40. package/src/config.ts +19 -15
  41. package/src/exec/executor.ts +4 -4
  42. package/src/graph/dependency-spec.ts +2 -2
  43. package/src/graph/folds.ts +236 -0
  44. package/src/graph/index.ts +8 -1
  45. package/src/graph/scheduler.ts +41 -15
  46. package/src/graph/task-graph.ts +135 -73
  47. package/src/handoff.ts +5 -0
  48. package/src/index.ts +2 -2
  49. package/src/orchestrator/affected-tasks.ts +20 -19
  50. package/src/orchestrator/download-policy.ts +16 -22
  51. package/src/orchestrator/excluded-keys.ts +4 -5
  52. package/src/orchestrator/execute-task.ts +29 -25
  53. package/src/orchestrator/hit-restore.ts +101 -27
  54. package/src/orchestrator/index.ts +1 -0
  55. package/src/orchestrator/keyed-projects.ts +12 -51
  56. package/src/orchestrator/local-shortcircuit.ts +9 -1
  57. package/src/orchestrator/persistent-task.ts +3 -3
  58. package/src/orchestrator/placement.ts +2 -2
  59. package/src/orchestrator/plan.ts +1 -1
  60. package/src/orchestrator/plugin-host.ts +66 -27
  61. package/src/orchestrator/plugin.ts +12 -2
  62. package/src/orchestrator/prepare.ts +12 -5
  63. package/src/orchestrator/projects.ts +7 -10
  64. package/src/orchestrator/run-report.ts +145 -46
  65. package/src/orchestrator/run-steps.ts +57 -0
  66. package/src/orchestrator/run.ts +76 -44
  67. package/src/orchestrator/sandbox-fix.ts +1 -1
  68. package/src/orchestrator/sandbox-request.ts +2 -2
  69. package/src/orchestrator/stable-keys.ts +59 -26
  70. package/src/orchestrator/steps.ts +26 -6
  71. package/src/orchestrator/task-hash.ts +31 -9
  72. package/src/orchestrator/telemetry-host.ts +3 -3
  73. package/src/orchestrator/telemetry.ts +9 -4
  74. package/src/orchestrator/upstream.ts +4 -181
  75. package/src/report/framed-output.ts +1 -1
  76. package/src/report/logger.ts +5 -2
  77. package/src/report/status-line.ts +7 -3
  78. package/src/report/summary.ts +6 -4
  79. package/src/settings.ts +11 -17
  80. package/src/util/index.ts +3 -0
  81. package/src/util/paths.ts +39 -5
  82. package/src/workspace/affected.ts +81 -2
  83. package/src/workspace/config-eval.ts +2 -2
  84. package/src/workspace/config-imports.ts +1 -1
  85. package/src/workspace/config-schema.ts +142 -143
  86. package/src/workspace/lockfile.ts +1 -1
  87. package/src/workspace/migration.ts +19 -3
  88. package/src/workspace/project-loader.ts +5 -4
@@ -8,11 +8,11 @@ import { realpathSync } from 'node:fs'
8
8
  import path from 'node:path'
9
9
  import {
10
10
  Cache,
11
- type CacheLayer,
12
11
  type CachePolicy,
13
12
  cachesNothing,
14
13
  formatCachePolicy,
15
14
  stopRuntimeProbes,
15
+ sweepStaging,
16
16
  } from '../cache/index.js'
17
17
  import { VERSION } from '../version.js'
18
18
  import {
@@ -76,9 +76,10 @@ import {
76
76
  warnOf,
77
77
  } from '../report/index.js'
78
78
  import { installPlugins, PluginSetupError, type RunReport } from './plugin.js'
79
- import { keepRunBytes } from './run-report.js'
79
+ import { keepsFailures, reportOf, RunKeeper } from './run-report.js'
80
80
  import { buildAdmission, executorDemand, resolveExecutors, teardownPlugins } from './plugin-host.js'
81
81
  import { createSteps } from './steps.js'
82
+ import { writeRunSteps } from './run-steps.js'
82
83
  import { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
83
84
  import { claimOutput, guardedOutput } from './output-host.js'
84
85
  import { assembleRunSummary } from './telemetry.js'
@@ -161,17 +162,14 @@ function effectiveCachePolicy(requested: CachePolicy, hasRemoteLayer: boolean):
161
162
  /**
162
163
  * The perf firewall for the local short-circuit. Always-on; the only
163
164
  * gates are correctness/no-op gates:
164
- * - LOCAL-ONLY cache. Behind a remote layer, `cache.get` is a remote
165
- * READ-THROUGH — the up-front classify is awaited before scheduling,
166
- * so N remote GETs would land on the critical path before any task
167
- * starts. Remote runs are owned by `startRemotePrefetch`, which
168
- * overlaps the GETs with execution instead (fire-and-forget) and
169
- * ingests hits into local for execute-task's lazy probe. The gate asks
170
- * the layer (`hasRemote`), not `instanceof LayeredCache` — a
171
- * third-party remote layer stalls on the classify just as hard.
172
165
  * - the policy reads locally (a `--no-cache`/`--force`/`--cache-policy=local:`
173
166
  * run reads nothing locally → nothing to restore → skip);
174
167
  * - there is at least one task.
168
+ * A remote layer does not close it: the local cache sits in front of every
169
+ * remote (#3619), so the classify probes that local store alone and a local
170
+ * hit runs ahead of its dependencies as it does with no remote; the remote
171
+ * GETs stay on `startRemotePrefetch`'s fire-and-forget path (M's solid
172
+ * run, 2026-10-16: a local hit waited 18 s on a dependency it never read).
175
173
  * A flat graph has no ordering to bypass, but the classify pass is worth
176
174
  * paying for anyway: it probes every stable key through ONE batched
177
175
  * `getMany` instead of a `cache.get` per task inside the run, which
@@ -179,12 +177,7 @@ function effectiveCachePolicy(requested: CachePolicy, hasRemoteLayer: boolean):
179
177
  * (2026-09-03, interleaved arms). The per-task correctness gates (stable
180
178
  * key, no workspace-outputs in graph) live in `startLocalShortCircuit`.
181
179
  */
182
- export function shouldShortCircuit(
183
- nodes: Map<string, TaskNode>,
184
- policy: CachePolicy,
185
- cache: CacheLayer,
186
- ): boolean {
187
- if (cache.hasRemote === true) return false
180
+ export function shouldShortCircuit(nodes: Map<string, TaskNode>, policy: CachePolicy): boolean {
188
181
  if (!policy.localRead) return false
189
182
  return nodes.size > 0
190
183
  }
@@ -358,7 +351,8 @@ async function runOnBus(
358
351
  takeOutput?: (claimed: { logger: Logger; owner: string }) => void,
359
352
  ): Promise<RunSummary> {
360
353
  const log = busLogger(bus)
361
- const { step, stepSync } = createSteps(log, showsSteps)
354
+ const steps = createSteps(log, showsSteps)
355
+ const { step, stepSync } = steps
362
356
  // A server's output goes through a cell of its own: a later watch cycle
363
357
  // that keeps the server points it at that cycle's bus (`RunOptions.keep`).
364
358
  const serverOuts = new Map<string, { bus: EventBus }>()
@@ -448,9 +442,30 @@ async function runOnBus(
448
442
  // carries it so analytics queries can group by invocation; plugins get it
449
443
  // in their teardown's `RunReport`.
450
444
  const runId = ulid()
445
+ // A killed run's restore staging (`.vx/tmp/<pid>-0`), once per run.
446
+ sweepStaging(prepared.workspaceRoot)
451
447
  const head = captureGitContext(prepared.workspaceRoot)
452
448
  const ciContext = detectCi(process.env)
453
- const keepRunLog = prepared.keepRunEntry !== undefined
449
+ const keepRun =
450
+ prepared.keepRunEntry === undefined
451
+ ? undefined
452
+ : {
453
+ failures: keepsFailures(
454
+ resolveSetting('cache.upload.failures', prepared.workspaceConfig).value,
455
+ ciContext.ci,
456
+ ),
457
+ }
458
+ const keeper =
459
+ prepared.keepRunEntry === undefined || keepRun === undefined
460
+ ? undefined
461
+ : new RunKeeper({
462
+ runId,
463
+ workspaceRoot: prepared.workspaceRoot,
464
+ nestedDirsByProject: prepared.nestedDirsByProject,
465
+ keep: prepared.keepRunEntry,
466
+ failures: keepRun.failures,
467
+ traffic: prepared.localCache.traffic,
468
+ })
454
469
  try {
455
470
  disposePlugins = await installPlugins({
456
471
  plugins: prepared.plugins,
@@ -579,6 +594,7 @@ async function runOnBus(
579
594
  onStop: () => {
580
595
  stopRuntimeProbes(hashCache.runtime, hashCache.workspaceRuntime)
581
596
  cache.stopUploads?.()
597
+ keeper?.stop()
582
598
  },
583
599
  })
584
600
  const { liveChildren, persistentRegistry } = stop
@@ -823,7 +839,7 @@ async function runOnBus(
823
839
  // unchanged (no upfront key pass, no local probing).
824
840
  let prefetchDone: Promise<void> = Promise.resolve()
825
841
  if (prepared.hasRemoteLayer) {
826
- prefetchDone = step('vx:prefetch', () =>
842
+ prefetchDone = steps.background('vx:prefetch', () =>
827
843
  startRemotePrefetch({
828
844
  nodes,
829
845
  cache,
@@ -852,16 +868,22 @@ async function runOnBus(
852
868
  // An empty local cache answers every probe with a miss: each task
853
869
  // derives its key as it starts instead (~100 ms before a cold 1,090-
854
870
  // package run's first spawn), and every sandboxed task runs.
871
+ const remote = cache.hasRemote === true
855
872
  const coldLocal =
856
- cache === prepared.localCache &&
857
- shouldShortCircuit(nodes, policy, cache) &&
858
- prepared.localCache.holdsNothing()
859
- if (coldLocal) prewarm()
860
- else if (shouldShortCircuit(nodes, policy, cache)) {
873
+ shouldShortCircuit(nodes, policy) && (remote || cache === prepared.localCache)
874
+ ? prepared.localCache.holdsNothing()
875
+ : false
876
+ // A remote may still hold what an empty local store does not: the
877
+ // prefetch asks it, and no confirmed miss warms the sandbox early.
878
+ if (coldLocal && !remote) prewarm()
879
+ else if (!coldLocal && shouldShortCircuit(nodes, policy)) {
861
880
  shortCircuit = await step('vx:keys', () =>
862
881
  startLocalShortCircuit({
863
882
  nodes,
864
- cache,
883
+ // Behind a remote, the local store alone: its hits are certain
884
+ // now, and a local miss may still be a remote hit.
885
+ cache: remote ? prepared.localCache : cache,
886
+ localOnly: remote,
865
887
  workspaceRoot,
866
888
  claimedFiles: fingerprintWatch.names,
867
889
  forwardArgs: options.forwardArgs,
@@ -872,7 +894,8 @@ async function runOnBus(
872
894
  }),
873
895
  )
874
896
  // A confirmed miss runs too.
875
- if (sandboxed.some((n) => shortCircuit.preProbed.get(n.id)?.hit === null)) prewarm()
897
+ if (!remote && sandboxed.some((n) => shortCircuit.preProbed.get(n.id)?.hit === null))
898
+ prewarm()
876
899
  }
877
900
 
878
901
  // Whether this task runs behind a failure (`continueMode: 'always'`
@@ -938,7 +961,7 @@ async function runOnBus(
938
961
  if (cleanOutputs !== undefined) scope.cleanOutputs = cleanOutputs
939
962
  if (telemetry?.track !== undefined) scope.track = telemetry.track
940
963
  if (explainMiss !== undefined) scope.explainMiss = explainMiss
941
- if (keepRunLog) scope.keepRunLog = true
964
+ if (keepRun !== undefined) scope.keepRun = keepRun
942
965
  if (sandboxArmer !== null) scope.armSandbox = () => sandboxArmer.arm()
943
966
  const argsOf = executeArgsOf(scope, {
944
967
  log: (node) => (node.config.exec?.persistent !== undefined ? serverLog(node.id) : log),
@@ -986,11 +1009,22 @@ async function runOnBus(
986
1009
  mark('classify + probe')
987
1010
  // A run whose every task is a confirmed local hit dispatches only
988
1011
  // restores, which no weight orders: the `schedule` stage is not asked.
1012
+ // A plugin's later weights (`ctx.refine`) re-rank the ready queues
1013
+ // while the graph runs; any that land before it starts, at its start.
1014
+ let reprioritize: ((weights: ReadonlyMap<string, number>) => void) | undefined
1015
+ let refinedEarly: ReadonlyMap<string, number> | undefined
1016
+ let graphOver = false
989
1017
  const priorities = [...nodes.values()].every(
990
1018
  (n) => n.config.exec?.command === undefined || shortCircuit.restoreTier.has(n.id),
991
1019
  )
992
1020
  ? new Map<string, number>()
993
- : await step('vx:schedule', () => prepared.priorities())
1021
+ : await prepared.priorities({
1022
+ step,
1023
+ refined: (merged) => {
1024
+ if (reprioritize !== undefined) reprioritize(merged)
1025
+ else if (!graphOver) refinedEarly = merged
1026
+ },
1027
+ })
994
1028
  mark('schedule')
995
1029
  // Judged as each task finishes, against the history BEFORE this run's
996
1030
  // rows land, so the row that prints next carries the verdict: nothing
@@ -1047,6 +1081,7 @@ async function runOnBus(
1047
1081
  if (cacheOff) o.cacheOff = true
1048
1082
  taint.settled(o)
1049
1083
  judgeFlaky(o)
1084
+ keeper?.taskDone(o)
1050
1085
  log.taskComplete(o.node, o)
1051
1086
  narrowDemand(o.node.id)
1052
1087
  },
@@ -1070,7 +1105,7 @@ async function runOnBus(
1070
1105
  ...(options.continueMode !== 'always' && taintSeeds.size === 0
1071
1106
  ? {
1072
1107
  settleNow: (node: TaskNode, upstream: TaskOutcome[]) =>
1073
- isGroupTask(node) && node.config.cache === undefined
1108
+ isGroupTask(node) && node.config.inputs === undefined
1074
1109
  ? unkeyedGroupOutcome(
1075
1110
  node,
1076
1111
  keyUpstream(node, upstream),
@@ -1083,6 +1118,10 @@ async function runOnBus(
1083
1118
  // A `schedule` plugin's weights; the scheduler keeps its structural
1084
1119
  // baseline as the tie-break. Empty map → baseline only.
1085
1120
  ...(priorities.size > 0 ? { priorities } : {}),
1121
+ reprioritize: (apply) => {
1122
+ if (refinedEarly !== undefined) apply(refinedEarly)
1123
+ reprioritize = apply
1124
+ },
1086
1125
  // The baseline under them; `most-work` when the workspace names none.
1087
1126
  ...(options.schedule !== undefined ? { strategy: options.schedule } : {}),
1088
1127
  // Local short-circuit: confirmed stable local hits the scheduler
@@ -1091,6 +1130,8 @@ async function runOnBus(
1091
1130
  restoreTier: shortCircuit.restoreTier,
1092
1131
  })
1093
1132
  stop.graphDone()
1133
+ reprioritize = undefined
1134
+ graphOver = true
1094
1135
 
1095
1136
  // Which persistent children outlive the graph, and the bounded SIGTERM
1096
1137
  // of the rest, before the summary prints. Scoped to the real CLI
@@ -1250,22 +1291,13 @@ async function runOnBus(
1250
1291
  // nothing the suite could see, because this gate is the rule.
1251
1292
  await step('vx:uploads', async () => {
1252
1293
  await prefetchDone
1253
- await cache.drainUploads?.()
1294
+ await Promise.all([cache.drainUploads?.(), keeper?.settled()])
1254
1295
  })
1296
+ writeRunSteps(prepared.cacheDir, runId, steps.ended, warnOf(log))
1255
1297
  // An upload that failed after the rows went in: its row says so too.
1256
1298
  if (prepared.localCache.uploads.failed > 0 && stop.stoppedBy === undefined && !historyAfterWait)
1257
1299
  recordHistory(() => recordLateRemoteErrors(prepared.localCache, records.runs, runId))
1258
- const report = await step('vx:keep-run', () =>
1259
- keepRunBytes(list, {
1260
- runId,
1261
- commit: head.commitSha,
1262
- ciRun: ciContext,
1263
- ok,
1264
- workspaceRoot: prepared.workspaceRoot,
1265
- nestedDirsByProject: prepared.nestedDirsByProject,
1266
- keep: prepared.keepRunEntry,
1267
- }),
1268
- )
1300
+ const report = reportOf(list, { runId, commit: head.commitSha, ciRun: ciContext, ok }, keeper)
1269
1301
  // Hand the per-run summary to the telemetry sinks + drain them. Only
1270
1302
  // when a sink is active (telemetry !== undefined) — otherwise this
1271
1303
  // whole block is skipped and the run is byte-identical to before.
@@ -1285,7 +1317,7 @@ async function runOnBus(
1285
1317
  })
1286
1318
  telemetry.emitSummary(summary)
1287
1319
  const sinks = telemetry
1288
- await step('vx:telemetry', () => sinks.flush())
1320
+ await sinks.flush(step)
1289
1321
  }
1290
1322
  // End-of-run plugin lifecycle: each plugin's teardown(). Crash-isolated
1291
1323
  // + time-bounded inside teardownPlugins, so a faulty plugin can neither
@@ -1477,7 +1509,7 @@ function noneAffected(prepared: PreparedRun, options: RunOptions): string {
1477
1509
  * task. Used by `--dry`.
1478
1510
  *
1479
1511
  * Side-effects are limited to opening + closing the local Cache handle
1480
- * (and running `cache.inputs.runtime` probe commands, which key
1512
+ * (and running `inputs.runtime` probe commands, which key
1481
1513
  * derivation requires). Cache probing is the byte-free `cache.has()`
1482
1514
  * existence check — no artifact download, no ingest, no accessed_at bump.
1483
1515
  */
@@ -1575,7 +1607,7 @@ async function planRestorable(
1575
1607
  const serves = [...nodes.values()].some(
1576
1608
  (n) => n.config.exec?.persistent !== undefined && !n.requested && n.surfaced !== true,
1577
1609
  )
1578
- if (!serves || !shouldShortCircuit(nodes, policy, prepared.cache)) return {}
1610
+ if (!serves || !shouldShortCircuit(nodes, policy)) return {}
1579
1611
  const keptOut = restoreTierExclusions(nodes, prepared.workspaceRoot)
1580
1612
  const stable = await deriveStableKeys({
1581
1613
  nodes,
@@ -85,7 +85,7 @@ export function sandboxFix(
85
85
  projectDir: string,
86
86
  mask: (text: string) => string = (t) => t,
87
87
  /**
88
- * The task's `cache.inputs.files`, undefined when it does not cache: a
88
+ * The task's `inputs.files`, undefined when it does not cache: a
89
89
  * write under one is granted exactly, and a read of the project no glob
90
90
  * matches is named for the key too.
91
91
  */
@@ -163,7 +163,7 @@ export function prepareSandbox(nodes: Iterable<TaskNode>): SandboxArmer | null {
163
163
  * cached path (through the executor) and the persistent path (spawned here).
164
164
  *
165
165
  * The user's grants derive NOTHING from `cache` (owner, 2026-09-05). Those
166
- * are two different questions: `cache.inputs` says what INVALIDATES the
166
+ * are two different questions: `inputs` says what INVALIDATES the
167
167
  * task, `sandbox.allow` says what it may TOUCH. Deriving one from the other
168
168
  * coupled them in both directions — a declaration added for caching
169
169
  * silently widened the sandbox, and a path the task needed had to be
@@ -476,7 +476,7 @@ export function withheldLinkLine(taskId: string, w: WithheldLink): string {
476
476
  `${w.name}, so an edit there would not re-run it. Add a \`dependsOn\` edge that ` +
477
477
  `reaches one (\`^build\` where ${w.name}#build keys its sources, or a \`source\` task: ` +
478
478
  `\`${w.name}#source\`), or grant and key the files yourself (\`allow.read\` plus ` +
479
- `\`cache.inputs.workspaceFiles\`).`
479
+ `\`inputs.workspaceFiles\`).`
480
480
  )
481
481
  }
482
482
 
@@ -5,13 +5,19 @@
5
5
  // out so the two callers can never drift on the stability gate.
6
6
  //
7
7
  // A task's key is "stable" only if its inputs can't be altered by an
8
- // upstream's outputs (a `cache.inputs.files` glob that could match a
8
+ // upstream's outputs (a `inputs.files` glob that could match a
9
9
  // sibling's declared output has a PRELIMINARY key until that upstream
10
10
  // runs — probing it would target the wrong artifact). Unstable tasks
11
11
  // fall back to lazy read-through in execute-task, which is always
12
12
  // correct. The check is conservative: when unsure, treat as unstable.
13
13
 
14
- import { isGroupTask, outputTakenBack, type TaskNode, type TaskOutcome } from '../graph/index.js'
14
+ import {
15
+ foldedDeps,
16
+ isGroupTask,
17
+ outputTakenBack,
18
+ type TaskNode,
19
+ type TaskOutcome,
20
+ } from '../graph/index.js'
15
21
  import type { CacheLayer, GitFilesCache } from '../cache/index.js'
16
22
  import {
17
23
  checkIgnore,
@@ -23,7 +29,6 @@ import {
23
29
  wholeIgnored,
24
30
  } from '../util/index.js'
25
31
  import { commandWriteReach, mayWriteFingerprint, undeclaredWriteReach } from './sandbox-request.js'
26
- import { foldedDeps } from './keyed-projects.js'
27
32
  import {
28
33
  computeGroupHash,
29
34
  computeTaskHash,
@@ -129,12 +134,12 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
129
134
  const unfoldedById = new Map<string, ProjectSet>()
130
135
  const wsRewriters = new Set<string>()
131
136
  // A cached rewriter matters only to a key that leaves a dependency's key
132
- // out: a `cache.inputs.tasks` filter. With none in the graph every edge
137
+ // out: a `inputs.tasks` filter. With none in the graph every edge
133
138
  // folds and the sets above stay empty, so they are not built (about 2 ms,
134
139
  // median, of a 27 ms memoised walk over the 3,000-task bench; item 750).
135
140
  let anyUnfolded = false
136
141
  for (const n of args.nodes.values()) {
137
- if (n.config.cache?.inputs?.tasks !== undefined) {
142
+ if (n.config.inputs?.tasks !== undefined) {
138
143
  anyUnfolded = true
139
144
  break
140
145
  }
@@ -241,7 +246,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
241
246
  // cascade (and forward their producers, above). They inherit
242
247
  // instability from any unstable member.
243
248
  // `computeGroupKey`, with the unkeyed branch kept synchronous.
244
- if (node.config.cache === undefined) {
249
+ if (node.config.inputs === undefined) {
245
250
  const key = computeGroupHash(upstream)
246
251
  keyById.set(id, key)
247
252
  args.groupKeys?.set(id, { key, upstream })
@@ -293,9 +298,8 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
293
298
 
294
299
  const cacheEnabled = node.config.cache !== undefined
295
300
  if (cacheEnabled && !unstable) stableKeys.push({ hash, node })
296
- // An uncached key reads its whole project, so `dependsOnSiblingOutputs`
297
- // (which answers false for it) is not the gate: any upstream that may
298
- // write into this project, or anywhere, leaves the key preliminary.
301
+ // An uncached key stays preliminary behind any upstream that may write
302
+ // into this project, or anywhere: none of its writes is declared.
299
303
  else if (
300
304
  !cacheEnabled &&
301
305
  !unstable &&
@@ -332,7 +336,7 @@ export function gatedOutputs(
332
336
  }
333
337
  if (outputsOf.size === 0) return out
334
338
  for (const n of nodes.values()) {
335
- const inputs = n.config.cache?.inputs?.files
339
+ const inputs = n.config.inputs?.files
336
340
  const outputs = outputsOf.get(n.projectName)
337
341
  if (inputs === undefined || outputs === undefined || isGroupTask(n)) continue
338
342
  let back: string[] | undefined
@@ -396,7 +400,7 @@ function readableOutputs(
396
400
  const probesAfterWritesOf = new WeakMap<ReadonlyMap<string, TaskNode>, ReadonlySet<string>>()
397
401
 
398
402
  /**
399
- * The cached tasks whose `cache.inputs.runtime` / `workspaceRuntime` probes
403
+ * The cached tasks whose `inputs.runtime` / `workspaceRuntime` probes
400
404
  * may read what an upstream writes this run while the key folds nothing of
401
405
  * that upstream (X-34). A probe is a shell command and may read anywhere,
402
406
  * so no glob bounds it, and its answer taken before the writer ran is the
@@ -424,8 +428,8 @@ export function probesAfterWrites(
424
428
  const late = new Set<string>()
425
429
  probesAfterWritesOf.set(nodes, late)
426
430
  const probes = (n: TaskNode): boolean =>
427
- (n.config.cache?.inputs?.runtime?.length ?? 0) > 0 ||
428
- (n.config.cache?.inputs?.workspaceRuntime?.length ?? 0) > 0
431
+ (n.config.inputs?.runtime?.length ?? 0) > 0 ||
432
+ (n.config.inputs?.workspaceRuntime?.length ?? 0) > 0
429
433
  let anyProbe = false
430
434
  for (const n of nodes.values()) {
431
435
  if (probes(n)) {
@@ -455,7 +459,7 @@ export function probesAfterWrites(
455
459
  let writer = false
456
460
  let unfolded = false
457
461
  // No filter and no order-only edge: the key folds every dependency.
458
- const foldsAll = node.orderOnly === undefined && node.config.cache?.inputs?.tasks === undefined
462
+ const foldsAll = node.orderOnly === undefined && node.config.inputs?.tasks === undefined
459
463
  let folds: Set<string> | undefined
460
464
  for (const dep of node.deps) {
461
465
  if (!nodes.has(dep)) continue
@@ -483,7 +487,7 @@ function foldedBy(
483
487
  upstream: TaskOutcome[],
484
488
  keyById: Map<string, string>,
485
489
  ): (dep: string) => boolean {
486
- const filter = node.config.cache?.inputs?.tasks
490
+ const filter = node.config.inputs?.tasks
487
491
  if (isGroupTask(node) || filter === undefined) return (dep) => keyById.has(dep)
488
492
  const hashes = new Set(
489
493
  filterUpstreamHashes(upstream, filter, node.projectName, node.id).map(([, h]) => h),
@@ -525,7 +529,7 @@ function synthUpstream(
525
529
  * `outputs.files` — direct or transitive — makes the key
526
530
  * preliminary. Project boundaries are hard, so only SAME-project
527
531
  * outputs can reach a project-relative input.
528
- * - `cache.inputs.workspaceFiles` is boundary-free: it can read ANY
532
+ * - `inputs.workspaceFiles` is boundary-free: it can read ANY
529
533
  * project's dir (so any upstream `outputs.files`, in any project,
530
534
  * matters) or a root-anchored location (so any upstream
531
535
  * `outputs.workspaceFiles` matters). Either → preliminary key. This
@@ -551,9 +555,9 @@ export function dependsOnSiblingOutputs(
551
555
  },
552
556
  ): boolean {
553
557
  const cache = node.config.cache
554
- // A cache-disabled task has no key to prefetch anyway; treat as
555
- // unstable-irrelevant (caller filters on cacheEnabled).
556
- if (cache === undefined) return false
558
+ const declared = node.config.inputs
559
+ // A task with no inputs has no key to take up front.
560
+ if (declared === undefined) return false
557
561
  if (upstreamOutputProjects.has(node.projectName)) {
558
562
  // A same-project producer whose writes are all declared reaches this
559
563
  // task's inputs only where the globs can meet: a `test` reading `src/**`
@@ -562,15 +566,21 @@ export function dependsOnSiblingOutputs(
562
566
  // projects (A-20). Undeclared writes, and a rewriter this key does not
563
567
  // fold, may land anywhere: those stay project-wide.
564
568
  if (sameProject === undefined || sameProject.wide?.has(node.projectName) === true) return true
565
- const inputs = cache.inputs?.files
569
+ const inputs = declared.files
566
570
  const outputs = sameProject.outputsOf.get(node.projectName) ?? []
567
- if (inputs === undefined || outputs.length === 0) return true
571
+ if (outputs.length === 0) return true
568
572
  // An output the reader's own `!` inputs take back whole is not in its
569
573
  // key: `['**/*', '!dist/**']` beside a `dist/**` producer keys the same
570
- // before and after it, which is what `run.rules.upfrontKeys` asks a config
571
- // to write (X-54).
574
+ // before and after it, which is what `vx lock` asks a config to write
575
+ // (X-54).
572
576
  // Nor is an output git ignores whole: a key reads only what git lists.
573
- const readable = sameProject.readableOf?.get(node.projectName) ?? outputs
577
+ // Unless an entry starts inside it (`dist/**`): naming an ignored path
578
+ // makes it an input (`ignoredReach` in cache/inputs.ts).
579
+ const readable = withNamedIgnored(
580
+ inputs,
581
+ outputs,
582
+ sameProject.readableOf?.get(node.projectName) ?? outputs,
583
+ )
574
584
  const back = inputs.filter((g) => g.startsWith('!')).map((g) => g.slice(1))
575
585
  const reach = back.length === 0 ? readable : readable.filter((o) => !outputTakenBack(o, back))
576
586
  if (reach.length > 0 && workspaceInputsReach(inputs, reach.map(literalPrefix))) return true
@@ -578,7 +588,7 @@ export function dependsOnSiblingOutputs(
578
588
  // that producer, its restore raced the producer's clean and extract
579
589
  // (M-5; invariant 2 of docs/design/overlapping-outputs-2026-09.md).
580
590
  // `outputsOf` holds this task's own globs too; set them aside once.
581
- const own = cache.outputs.files
591
+ const own = cache?.outputs.files ?? []
582
592
  if (own.length > 0) {
583
593
  const others = [...outputs]
584
594
  for (const g of own) {
@@ -597,7 +607,7 @@ export function dependsOnSiblingOutputs(
597
607
  // `app` read `gen/**` project-relative), so the mere presence of a
598
608
  // workspace-output producer upstream makes the key preliminary.
599
609
  if (hasWsOutputUpstream) return true
600
- const wsInputs = cache.inputs?.workspaceFiles ?? []
610
+ const wsInputs = declared.workspaceFiles ?? []
601
611
  if (wsInputs.length > 0 && upstreamOutputProjects.size > 0) {
602
612
  // Without the dirs the old answer stands: a workspace reader over any
603
613
  // producer is preliminary. With them, only a reader whose globs can
@@ -617,6 +627,29 @@ export function dependsOnSiblingOutputs(
617
627
  return false
618
628
  }
619
629
 
630
+ /**
631
+ * `readable`, plus each output git ignores whole that a positive entry of
632
+ * `inputs` starts inside: its literal prefix at or below the output's.
633
+ */
634
+ function withNamedIgnored(
635
+ inputs: readonly string[],
636
+ outputs: readonly string[],
637
+ readable: readonly string[],
638
+ ): readonly string[] {
639
+ if (readable.length === outputs.length) return readable
640
+ const named = outputs.filter((o) => {
641
+ if (readable.includes(o)) return false
642
+ const at = literalPrefix(o)
643
+ if (at === '') return false
644
+ return inputs.some((g) => {
645
+ if (g.startsWith('!')) return false
646
+ const p = literalPrefix(g)
647
+ return p === at || p.startsWith(`${at}/`)
648
+ })
649
+ })
650
+ return named.length === 0 ? readable : [...readable, ...named]
651
+ }
652
+
620
653
  /**
621
654
  * Can any positive `workspaceFiles` entry match a path inside one of
622
655
  * `dirs` (workspace-relative project dirs)? An entry's reach is its
@@ -3,25 +3,45 @@
3
3
  // `:teardown`. `:` is no task id's separator, so a step never collides with
4
4
  // a project named `vx` or a plugin package's own `#setup` task. A step is
5
5
  // not a task: no stat, history row or telemetry record counts it.
6
+ //
7
+ // A run shows only the steps it waits on, before its first task or its end
8
+ // (M, 2026-10-16). One that runs beside the tasks (`vx:prefetch`) is a
9
+ // `background` step: never shown, and kept for `vx last` (run-steps.ts).
6
10
  import type { Logger } from '../report/index.js'
11
+ import type { RunStep } from './run-steps.js'
7
12
 
8
13
  export interface Steps {
9
14
  /** Time `work` as step `id` when steps show; else run it as it is. */
10
15
  step<T>(id: string, work: () => Promise<T>): Promise<T>
11
16
  stepSync<T>(id: string, work: () => T): T
12
- }
13
-
14
- const unseen: Steps = {
15
- step: (_id, work) => work(),
16
- stepSync: (_id, work) => work(),
17
+ /** Time `work`, which the run does not wait on, as step `id` into `background`. */
18
+ background<T>(id: string, work: () => Promise<T>): Promise<T>
19
+ /** The background steps that have ended. */
20
+ readonly ended: readonly RunStep[]
17
21
  }
18
22
 
19
23
  /** Steps that tell `log`, or none when nothing renders them (zero cost). */
20
24
  export function createSteps(log: Logger, shown: boolean): Steps {
21
- if (!shown) return unseen
25
+ const ended: RunStep[] = []
26
+ // Timed whether or not anything shows: only a run with a remote starts one.
27
+ const background = async <T>(id: string, work: () => Promise<T>): Promise<T> => {
28
+ const t0 = performance.now()
29
+ let ok = false
30
+ try {
31
+ const value = await work()
32
+ ok = true
33
+ return value
34
+ } finally {
35
+ ended.push({ id, durationMs: Math.round(performance.now() - t0), ok })
36
+ }
37
+ }
38
+ if (!shown)
39
+ return { step: (_id, work) => work(), stepSync: (_id, work) => work(), background, ended }
22
40
  const end = (id: string, t0: number, ok: boolean): void =>
23
41
  log.stepEnd?.(id, { durationMs: performance.now() - t0, ok })
24
42
  return {
43
+ background,
44
+ ended,
25
45
  async step(id, work) {
26
46
  const t0 = performance.now()
27
47
  log.stepStart?.(id)