@vzn/vx 0.0.636 → 0.0.637

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 (141) hide show
  1. package/README.md +39 -11
  2. package/docs/caching.md +213 -207
  3. package/docs/cli.md +699 -655
  4. package/docs/execution.md +67 -75
  5. package/docs/features.md +44 -40
  6. package/docs/patterns.md +26 -25
  7. package/docs/schema.md +376 -198
  8. package/docs/security.md +76 -39
  9. package/launcher.sh +43 -0
  10. package/package.json +5 -5
  11. package/schemas/cache.json +41 -19
  12. package/schemas/info.json +23 -11
  13. package/schemas/last.json +48 -2
  14. package/schemas/lock.json +1 -1
  15. package/schemas/plan.json +2 -2
  16. package/schemas/show.json +5 -3
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +16 -12
  19. package/src/bin.ts +17 -2
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +105 -53
  22. package/src/cache/git-inputs.ts +277 -63
  23. package/src/cache/index.ts +7 -1
  24. package/src/cache/inputs.ts +125 -35
  25. package/src/cache/key-fold.ts +8 -7
  26. package/src/cache/layer.ts +8 -17
  27. package/src/cache/layered-cache.ts +16 -102
  28. package/src/cache/policy.ts +25 -29
  29. package/src/cache/run-history.ts +36 -14
  30. package/src/cache/schema.ts +21 -23
  31. package/src/cache/scoped-cache.ts +224 -0
  32. package/src/cli/cache.ts +150 -28
  33. package/src/cli/completions.ts +2 -2
  34. package/src/cli/core-alias.ts +1 -1
  35. package/src/cli/foreign-flags.ts +13 -12
  36. package/src/cli/help.ts +94 -63
  37. package/src/cli/index.ts +42 -13
  38. package/src/cli/info.ts +38 -23
  39. package/src/cli/init.ts +115 -406
  40. package/src/cli/last.ts +145 -4
  41. package/src/cli/lock.ts +333 -111
  42. package/src/cli/plan-format.ts +2 -2
  43. package/src/cli/plugin-commands.ts +5 -9
  44. package/src/cli/plugin-templates.ts +10 -10
  45. package/src/cli/run.ts +103 -149
  46. package/src/cli/select.ts +33 -39
  47. package/src/cli/show.ts +17 -19
  48. package/src/cli/task-verb.ts +2 -9
  49. package/src/cli/upgrade.ts +402 -145
  50. package/src/cli/watch-cycle.ts +9 -2
  51. package/src/cli/watch-filter.ts +33 -60
  52. package/src/cli/watch-fs.ts +29 -16
  53. package/src/cli/watch-judge.ts +12 -3
  54. package/src/cli/watch-set.ts +16 -42
  55. package/src/cli/watch.ts +188 -215
  56. package/src/cli/why.ts +28 -17
  57. package/src/cli/workspace-config.ts +31 -68
  58. package/src/config.ts +169 -70
  59. package/src/exec/env.ts +1 -31
  60. package/src/exec/executor.ts +1 -5
  61. package/src/exec/kill-tree.ts +2 -1
  62. package/src/exec/sandbox-runtime.ts +12 -1
  63. package/src/exec/sandbox-violations.ts +9 -1
  64. package/src/graph/dependency-spec.ts +1 -1
  65. package/src/graph/index.ts +1 -0
  66. package/src/graph/priorities.ts +1 -1
  67. package/src/graph/scheduler.ts +19 -7
  68. package/src/graph/task-graph.ts +44 -20
  69. package/src/handoff.ts +169 -0
  70. package/src/index.ts +24 -57
  71. package/src/orchestrator/affected-tasks.ts +7 -5
  72. package/src/orchestrator/command-windows.ts +44 -0
  73. package/src/orchestrator/deferred-outputs.ts +12 -10
  74. package/src/orchestrator/doctor.ts +98 -110
  75. package/src/orchestrator/download-policy.ts +3 -3
  76. package/src/orchestrator/events.ts +19 -3
  77. package/src/orchestrator/excluded-keys.ts +0 -2
  78. package/src/orchestrator/execute-task.ts +158 -63
  79. package/src/orchestrator/fingerprint-watch.ts +18 -15
  80. package/src/orchestrator/forecast.ts +10 -2
  81. package/src/orchestrator/framed-output.ts +8 -3
  82. package/src/orchestrator/graph-stage.ts +5 -5
  83. package/src/orchestrator/history.ts +70 -30
  84. package/src/orchestrator/hit-restore.ts +15 -3
  85. package/src/orchestrator/index.ts +18 -4
  86. package/src/orchestrator/local-shortcircuit.ts +2 -1
  87. package/src/orchestrator/lockfile-claim.ts +79 -20
  88. package/src/orchestrator/logger.ts +71 -39
  89. package/src/orchestrator/metrics.ts +71 -6
  90. package/src/orchestrator/miss-save.ts +3 -0
  91. package/src/orchestrator/options.ts +42 -29
  92. package/src/orchestrator/output-host.ts +72 -0
  93. package/src/orchestrator/plan.ts +6 -8
  94. package/src/orchestrator/plugin-host.ts +87 -63
  95. package/src/orchestrator/plugin.ts +94 -60
  96. package/src/orchestrator/prepare.ts +113 -72
  97. package/src/orchestrator/projects.ts +102 -99
  98. package/src/orchestrator/remote-prefetch.ts +2 -1
  99. package/src/orchestrator/run-context.ts +25 -6
  100. package/src/orchestrator/run-failures.ts +17 -8
  101. package/src/orchestrator/run-lock.ts +1 -1
  102. package/src/orchestrator/run-report.ts +9 -9
  103. package/src/orchestrator/run-settings.ts +48 -0
  104. package/src/orchestrator/run.ts +121 -95
  105. package/src/orchestrator/sandbox-fix.ts +39 -20
  106. package/src/orchestrator/sandbox-request.ts +84 -13
  107. package/src/orchestrator/signals.ts +1 -1
  108. package/src/orchestrator/stable-keys.ts +13 -11
  109. package/src/orchestrator/status-line.ts +48 -6
  110. package/src/orchestrator/summary.ts +46 -62
  111. package/src/orchestrator/task-hash.ts +12 -83
  112. package/src/orchestrator/telemetry.ts +3 -3
  113. package/src/settings.ts +417 -0
  114. package/src/util/errors.ts +1 -1
  115. package/src/util/essential-env.ts +31 -0
  116. package/src/util/hide-env.ts +107 -0
  117. package/src/util/index.ts +8 -2
  118. package/src/util/paths.ts +10 -0
  119. package/src/util/settle.ts +49 -30
  120. package/src/util/size.ts +23 -4
  121. package/src/util/timing.ts +3 -2
  122. package/src/util/ulid.ts +1 -1
  123. package/src/util/verbs.ts +0 -15
  124. package/src/workspace/affected.ts +77 -222
  125. package/src/workspace/config-eval.ts +15 -20
  126. package/src/workspace/config-imports.ts +78 -3
  127. package/src/workspace/config-schema.ts +209 -268
  128. package/src/workspace/dependency-rules.ts +57 -0
  129. package/src/workspace/filter.ts +2 -2
  130. package/src/workspace/fingerprint.ts +11 -121
  131. package/src/workspace/index.ts +20 -27
  132. package/src/workspace/load-reads.ts +4 -4
  133. package/src/workspace/lockfile.ts +96 -140
  134. package/src/workspace/migration.ts +242 -146
  135. package/src/workspace/package-graph.ts +30 -216
  136. package/src/workspace/project-loader.ts +80 -285
  137. package/src/workspace/repo-id.ts +2 -1
  138. package/src/workspace/workspace.ts +362 -831
  139. package/src/cache/config-evals.ts +0 -130
  140. package/src/workspace/config-cache.ts +0 -661
  141. package/src/workspace/migrate-scripts.ts +0 -1121
@@ -4,30 +4,30 @@
4
4
  // plugin `project` stage, which can give tasks to a package that never
5
5
  // wrote a `vx.config.ts`.
6
6
 
7
+ import { stat } from 'node:fs/promises'
7
8
  import path from 'node:path'
8
9
  import type { ProjectConfig, TaskConfig } from '../config.js'
9
10
  import {
11
+ checkProjectNames,
10
12
  frozenProjectConfigs,
11
- loadProjectConfigs,
12
13
  LOCKFILE_NAME,
14
+ lockedProjects,
15
+ NO_LOCK,
16
+ pathName,
17
+ readLockfile,
13
18
  validateProjectConfig,
14
- type LoadProjectConfigOptions,
15
- type LoadReads,
16
19
  type Lockfile,
17
20
  type PackageGraph,
18
21
  type ProjectEntry,
19
22
  type ProjectMeta,
20
- type Workspace,
21
23
  } from '../workspace/index.js'
22
24
  import type { WorkspaceConfig } from '../config.js'
23
- import { Cache, lazyGitEnumeration, type LazyGitEnumeration } from '../cache/index.js'
24
- import { UserError } from '../util/index.js'
25
+ import { lazyGitEnumeration, type LazyGitEnumeration } from '../cache/index.js'
26
+ import { relPosix, UserError } from '../util/index.js'
25
27
  import {
26
- computeWorkspaceFingerprint,
27
- listProjects,
28
- loadWorkspace,
29
28
  loadWorkspaceConfig,
30
- namedProject,
29
+ discoveredProject,
30
+ linkDiscoveredEdges,
31
31
  resolveCacheDir,
32
32
  validateWorkspace,
33
33
  } from '../workspace/index.js'
@@ -61,7 +61,7 @@ export async function loadWorkspacePlugins(
61
61
  // the loader would refuse from a user (`concurrency: -3`) hung the run,
62
62
  // and `cacheDir: 42` reached `path.resolve` as a TypeError.
63
63
  await applyConfigHooks(plugins, workspaceConfig, { workspaceRoot, warn }, (plugin) =>
64
- validateWorkspace(workspaceConfig, `vx.workspace (after plugin '${plugin.name}')`),
64
+ validateWorkspace(workspaceConfig, `.vx/workspace (after plugin '${plugin.name}')`),
65
65
  )
66
66
  return { workspaceConfig, plugins }
67
67
  }
@@ -77,6 +77,15 @@ export function gitOfDiscovery(projects: readonly ProjectMeta[]): LazyGitEnumera
77
77
  return discoveredGit.get(projects)
78
78
  }
79
79
 
80
+ /** The lock a discovery read, by the array it returned: a later config load reads no second copy. */
81
+ const discoveredLock = new WeakMap<readonly ProjectMeta[], () => Promise<Lockfile>>()
82
+
83
+ export function lockOfDiscovery(
84
+ projects: readonly ProjectMeta[],
85
+ ): (() => Promise<Lockfile>) | undefined {
86
+ return discoveredLock.get(projects)
87
+ }
88
+
80
89
  /**
81
90
  * The package graph a selection pass built over a discovery, by that
82
91
  * discovery's array, when it folded no task edge in: the graph a run that
@@ -93,34 +102,40 @@ export function keepDiscoveryGraph(projects: readonly ProjectMeta[], graph: Pack
93
102
  }
94
103
 
95
104
  /**
96
- * The workspace's projects: core's discovery, then the plugin `discover`
97
- * stage. With no plugin declaring it, `listProjects` alone.
105
+ * The workspace's projects: the directories the lock records (a run never
106
+ * walks for configs), then the plugin `discover` stage, which may add
107
+ * directories, name path-named ones and give edges. Sorted by name; two
108
+ * projects of one name are refused.
98
109
  */
99
110
  export async function discoverProjects(
100
- workspace: Workspace,
111
+ workspaceRoot: string,
112
+ lock: () => Promise<Lockfile>,
101
113
  plugins: readonly VxPlugin[],
102
114
  cacheDir: string,
103
115
  warn: (message: string) => void,
104
116
  /** The run's enumeration, which `DiscoverContext.worktreeChanges` reads (and starts). */
105
- git: LazyGitEnumeration = lazyGitEnumeration(workspace.root),
117
+ git: LazyGitEnumeration = lazyGitEnumeration(workspaceRoot),
106
118
  ): Promise<ProjectMeta[]> {
107
- const projects = await listProjects(workspace)
119
+ const projects = lockedProjects(workspaceRoot, (await lock()).projects)
108
120
  // Registered whatever the hooks: an `--affected` selection reads its
109
121
  // untracked files from this walk, and the run reuses it (I-26).
110
122
  discoveredGit.set(projects, git)
111
- if (!hasHook(plugins, 'discover')) return projects
123
+ discoveredLock.set(projects, lock)
124
+ if (!hasHook(plugins, 'discover')) return checkProjectNames(workspaceRoot, projects)
112
125
  const worktreeChanges = (): Promise<readonly string[] | null> =>
113
126
  git.start().then(
114
127
  (e) => e.changed,
115
128
  () => null,
116
129
  )
117
- await applyDiscoverHooks(
130
+ const edges = await applyDiscoverHooks(
118
131
  plugins,
119
132
  projects,
120
- { workspaceRoot: workspace.root, cacheDir, warn, worktreeChanges },
121
- (named, plugin) => namedProject(workspace, projects, named, plugin.name),
133
+ { workspaceRoot, cacheDir, warn, worktreeChanges },
134
+ (entry, plugin) => discoveredProject(workspaceRoot, projects, entry, plugin.name),
122
135
  )
123
- return projects.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
136
+ checkProjectNames(workspaceRoot, projects)
137
+ linkDiscoveredEdges(workspaceRoot, projects, edges)
138
+ return projects
124
139
  }
125
140
 
126
141
  export type LoadProjectsArgs = LoadProjectsBase &
@@ -142,12 +157,11 @@ interface LoadProjectsBase {
142
157
  */
143
158
  seeds: 'all' | Iterable<string>
144
159
  /**
145
- * Read configs from the lock instead of evaluating them (`--frozen`),
146
- * asked only when a config is to be read: a run whose every config the
147
- * CLI's selection pass staged never parses it again.
160
+ * The lock configs come from, asked only when a config is to be read (a
161
+ * run whose every config the CLI's selection pass staged never parses
162
+ * it). `vx lock` hands the one it is about to write.
148
163
  */
149
- lock: (() => Promise<Lockfile>) | null
150
- evalCache: LoadProjectConfigOptions['evalCache']
164
+ configs: () => Promise<Lockfile>
151
165
  warn: (message: string) => void
152
166
  /**
153
167
  * Entries a load in this same process already produced with the same
@@ -180,7 +194,8 @@ export interface LoadedProjects {
180
194
  * enters scope.
181
195
  */
182
196
  export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjects> {
183
- const { plugins, lock, workspaceRoot } = args
197
+ const { plugins, workspaceRoot } = args
198
+ const lock = args.configs
184
199
  const graph = args.closure ? args.packageGraph : null
185
200
  // A package with no config file declares no tasks — unless a plugin
186
201
  // fills the `project` stage, in which case it is a project the stage may
@@ -238,27 +253,13 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
238
253
  // deps by design), so its config has to be pulled in — and that config
239
254
  // may declare cross edges of its own. The common case (no cross deps)
240
255
  // is a single round, identical to loading the closure in one batch.
241
- // Turbo spells the root package `//` (`dependsOn: ['//#lint']`); vx names
242
- // the root project by its package.json name, so the spelling is rewritten
243
- // to it once, here, for the closure, the graph and `cache.inputs.tasks`
244
- // alike. With no root project it stays, and the graph's refusal says so.
256
+ // Turbo spells the root package `//` (`dependsOn: ['//#lint']`), as vx
257
+ // names the root project unless something names it; a named root's
258
+ // spelling is rewritten to its name once, here, for the closure, the
259
+ // graph and `cache.inputs.tasks` alike. With no root project it stays,
260
+ // and the graph's refusal says so.
245
261
  const root = path.resolve(workspaceRoot)
246
262
  const rootName = args.projectMetas.find((m) => path.resolve(m.dir) === root)?.name
247
- // A project the lock holds whose config file is gone ran nothing under
248
- // --frozen, green, though the lock promises what runs is what was
249
- // locked (X-144); a run never even asks such a project for a task.
250
- // Refused as a moved config is (D-88). With no lock at all, nothing was
251
- // locked: a repo of config-less projects needs none.
252
- if (lock) {
253
- const bare = args.projectMetas.filter((m) => m.configPath === null)
254
- const held = bare.length === 0 ? null : await lock().catch(() => null)
255
- const gone = held === null ? undefined : bare.find((m) => Object.hasOwn(held.projects, m.name))
256
- if (gone !== undefined && held !== null)
257
- throw new UserError(
258
- `${LOCKFILE_NAME} locks "${gone.name}" at ${held.projects[gone.name]!.configPath}, but it has no config now — ` +
259
- `run \`vx lock\` to refresh, or delete ${LOCKFILE_NAME}`,
260
- )
261
- }
262
263
  const projects = new Map<string, ProjectEntry>()
263
264
  while (pending.length > 0) {
264
265
  const round = pending.splice(0, pending.length)
@@ -269,14 +270,30 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
269
270
  (m): m is ProjectMeta & { configPath: string } =>
270
271
  typeof m.configPath === 'string' && args.staged?.get(m.name) === undefined,
271
272
  )
272
- const loaded = lock
273
- ? withFile.length === 0
274
- ? []
275
- : await frozenProjectConfigs(await lock(), withFile, workspaceRoot, args.evalCache?.store)
276
- : await loadProjectConfigs(
277
- withFile.map((m) => m.configPath),
278
- args.evalCache !== undefined ? { evalCache: args.evalCache } : {},
279
- )
273
+ // A locked project whose config file is gone ran as locked, green,
274
+ // though nothing declares it any more (X-144): asked only of the
275
+ // configs this run reads.
276
+ const present = await Promise.all(withFile.map((m) => Bun.file(m.configPath).exists()))
277
+ const gone = withFile.find((_, i) => !present[i])
278
+ if (gone !== undefined) {
279
+ const rel = relPosix(workspaceRoot, gone.dir) || '.'
280
+ const named = gone.name === pathName(workspaceRoot, gone.dir) ? '' : ` ("${gone.name}")`
281
+ const file = relPosix(gone.dir, gone.configPath)
282
+ // exists() says false for a file behind a directory this user may not
283
+ // search too; asked only on this failure path, so a run pays no stat.
284
+ const denied = await stat(gone.configPath).then(
285
+ () => null,
286
+ (err: NodeJS.ErrnoException) =>
287
+ err.code === 'EACCES' || err.code === 'EPERM' ? err.code : null,
288
+ )
289
+ throw new UserError(
290
+ denied !== null
291
+ ? `${LOCKFILE_NAME} locks ${rel}${named}, but its ${file} is not readable by this user (${denied})`
292
+ : `${LOCKFILE_NAME} locks ${rel}${named}, but its ${file} is gone — run \`vx lock\``,
293
+ )
294
+ }
295
+ const loaded =
296
+ withFile.length === 0 ? [] : frozenProjectConfigs(await lock(), withFile, workspaceRoot)
280
297
  let next = 0
281
298
  for (const meta of round) {
282
299
  const pre = args.staged?.get(meta.name)
@@ -305,10 +322,14 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
305
322
  warn: args.warn,
306
323
  name: meta.name,
307
324
  dir: meta.dir,
308
- packageJson: meta.packageJson as unknown as Readonly<Record<string, unknown>>,
309
325
  projects: args.projectMetas,
310
326
  },
311
- (plugin) => validateProjectConfig(config, `${where} (after plugin '${plugin.name}')`),
327
+ (plugin) =>
328
+ validateProjectConfig(
329
+ config,
330
+ `${where} (after plugin '${plugin.name}')`,
331
+ meta.configPath,
332
+ ),
312
333
  )
313
334
  }
314
335
  const built = withDefaultBuild(config)
@@ -396,57 +417,39 @@ function crossDepProjects(config: ProjectConfig): string[] {
396
417
  }
397
418
 
398
419
  /**
399
- * The run path's view of a workspace's projects for a reader — `vx show`,
400
- * the MCP server, an embedder: discovery, the plugin `config` and
401
- * `project` stages, and the local cache opened only to serve cached
402
- * evaluations, so a pure config costs a stat, not an evaluation. `scope`
403
- * is every project or a list of names; no closure, no lock (a reader
404
- * reads live, as a default run does). Plugin warnings go to `warn`.
420
+ * The run path's view of a workspace's projects for a reader — a plugin's
421
+ * verb, an embedder: the lock, the plugin `config`, `discover` and
422
+ * `project` stages. `scope` is every project or a list of names; no
423
+ * closure. No lock is `NO_LOCK`. Plugin warnings go to `warn`.
405
424
  */
406
425
  export async function loadResolvedProjects(
407
426
  workspaceRoot: string,
408
427
  opts: { scope?: 'all' | readonly string[]; warn?: (message: string) => void } = {},
409
428
  ): Promise<Map<string, ProjectEntry>> {
410
429
  const warn = opts.warn ?? ((): void => {})
411
- const reads: LoadReads = new Map()
412
430
  const { workspaceConfig, plugins } = await loadWorkspacePlugins(workspaceRoot, warn)
413
431
  const cacheDir = resolveCacheDir(workspaceRoot, workspaceConfig)
414
- const metas = await discoverProjects(
415
- await loadWorkspace(workspaceRoot, reads),
416
- plugins,
432
+ const lock = lockReader(workspaceRoot)
433
+ const metas = await discoverProjects(workspaceRoot, lock, plugins, cacheDir, warn)
434
+ const loaded = await loadProjects({
435
+ workspaceRoot,
417
436
  cacheDir,
437
+ plugins,
438
+ projectMetas: metas,
439
+ seeds: opts.scope ?? 'all',
440
+ closure: false,
441
+ configs: lock,
418
442
  warn,
419
- )
420
- // Opened as `vx last` opens it: a reader makes no index where there is
421
- // none and never resets an earlier schema's (C-5). One it cannot read
422
- // serves nothing; the configs evaluate live.
423
- let cache: Cache | undefined
424
- try {
425
- cache = Cache.inspect(cacheDir)
426
- } catch (err) {
427
- if (!(err instanceof UserError)) throw err
428
- }
429
- try {
430
- const loaded = await loadProjects({
431
- workspaceRoot,
432
- cacheDir,
433
- plugins,
434
- projectMetas: metas,
435
- seeds: opts.scope ?? 'all',
436
- closure: false,
437
- lock: null,
438
- evalCache:
439
- cache === undefined
440
- ? undefined
441
- : {
442
- store: cache,
443
- workspaceRoot,
444
- workspaceFingerprint: await computeWorkspaceFingerprint(workspaceRoot, reads),
445
- },
446
- warn,
447
- })
448
- return loaded.projects
449
- } finally {
450
- cache?.close()
451
- }
443
+ })
444
+ return loaded.projects
445
+ }
446
+
447
+ /** The lock, read once on first ask; no lock is `NO_LOCK`. */
448
+ export function lockReader(workspaceRoot: string): () => Promise<Lockfile> {
449
+ let read: Promise<Lockfile> | undefined
450
+ return () =>
451
+ (read ??= readLockfile(workspaceRoot).then((lock) => {
452
+ if (lock === null) throw new UserError(NO_LOCK)
453
+ return lock
454
+ }))
452
455
  }
@@ -30,7 +30,8 @@ export interface PrefetchArgs {
30
30
  /** A layer whose `hasRemote` is true — the caller's gate. */
31
31
  cache: CacheLayer
32
32
  workspaceRoot: string
33
- workspaceFingerprint: string
33
+ /** The root files the plugins claim (`DeriveStableKeysArgs.claimedFiles`). */
34
+ claimedFiles: readonly string[]
34
35
  forwardArgs?: readonly string[] | undefined
35
36
  nestedDirsByProject: Map<string, string[]>
36
37
  gitFilesCache: GitFilesCache
@@ -13,7 +13,8 @@
13
13
  import fs from 'node:fs'
14
14
  import os from 'node:os'
15
15
  import path from 'node:path'
16
- import { executablePath, relPosix, ulid, xxh3hex } from '../util/index.js'
16
+ import { executablePath, gitEnv, relPosix, ulid, xxh3hex } from '../util/index.js'
17
+ import { resolveSetting } from '../settings.js'
17
18
 
18
19
  export interface GitContext {
19
20
  /** `git rev-parse HEAD`, or null outside a repo / on failure. */
@@ -86,6 +87,7 @@ export function captureGitContext(
86
87
  '--abbrev-ref',
87
88
  'HEAD',
88
89
  ],
90
+ env: gitEnv(),
89
91
  stdout: 'pipe',
90
92
  stderr: 'ignore',
91
93
  })
@@ -280,6 +282,7 @@ export function captureDefaultBranch(
280
282
  '--short',
281
283
  'refs/remotes/origin/HEAD',
282
284
  ],
285
+ env: gitEnv(),
283
286
  stdout: 'pipe',
284
287
  stderr: 'ignore',
285
288
  })
@@ -445,10 +448,10 @@ export function normalizeRemoteUrl(raw: string): string {
445
448
  * (telemetry schema v2). Derivation ladder:
446
449
  * 1. git remote origin URL, normalized → xxh3 (same id from any
447
450
  * machine's checkout of the same repo);
448
- * 2. no remote → a salt persisted at `<root>/.vx/workspace-id` (the
449
- * checkout keeps a stable identity across runs; `.vx/` is already
450
- * gitignored infrastructure);
451
- * 3. unwritable `.vx/` → the root path itself (stable per machine).
451
+ * 2. no remote → a salt persisted at `<root>/.vx/cache/workspace-id`
452
+ * (the checkout keeps a stable identity across runs; `.vx/cache` holds
453
+ * the machine-local state, never committed);
454
+ * 3. unwritable `.vx/cache` → the root path itself (stable per machine).
452
455
  * The URL comes from the run's own `git var -l` when it has one, else one
453
456
  * `git` spawn behind try/catch; call sites gate on telemetry being active
454
457
  * so a plain run never pays it. Never throws.
@@ -473,6 +476,7 @@ export function captureWorkspaceIdentity(
473
476
  // different proxies would derive different workspace ids. The raw
474
477
  // configured URL is the identity.
475
478
  cmd: [executablePath('git'), '-C', workspaceRoot, 'config', '--get', 'remote.origin.url'],
479
+ env: gitEnv(),
476
480
  stdout: 'pipe',
477
481
  stderr: 'ignore',
478
482
  })
@@ -488,7 +492,7 @@ export function captureWorkspaceIdentity(
488
492
  // git unavailable — fall through to the salt.
489
493
  }
490
494
  try {
491
- const saltPath = path.join(workspaceRoot, '.vx', 'workspace-id')
495
+ const saltPath = path.join(workspaceRoot, '.vx', 'cache', 'workspace-id')
492
496
  let salt: string
493
497
  try {
494
498
  salt = fs.readFileSync(saltPath, 'utf8').trim()
@@ -503,3 +507,18 @@ export function captureWorkspaceIdentity(
503
507
  return { id: xxh3hex(workspaceRoot), name: base, ...where }
504
508
  }
505
509
  }
510
+
511
+ /**
512
+ * The run's branch: `run.branch` (the flag given, else `VX_RUN_BRANCH`,
513
+ * else the workspace's), else HEAD's, else the CI provider's for a
514
+ * detached checkout. What run history records and a forecast reads first.
515
+ */
516
+ export function currentBranch(
517
+ workspaceRoot: string,
518
+ workspace?: unknown,
519
+ flag?: string,
520
+ env: NodeJS.ProcessEnv = process.env,
521
+ ): string | null {
522
+ const set = resolveSetting('run.branch', workspace, flag, env).value as string | undefined
523
+ return set ?? captureGitContext(workspaceRoot, null, env).branch
524
+ }
@@ -1,6 +1,5 @@
1
1
  // A failed run's task output and the files it names, so an agent reads why
2
- // a task failed without the terminal (`vx last --format json`, vx-mcp's
3
- // getFailures). One file per failed run, `<cacheDir>/failures/<runId>.json`,
2
+ // a task failed without the terminal (`vx last --format json`). One file per failed run, `<cacheDir>/failures/<runId>.json`,
4
3
  // the newest 50 kept: files, not cache.db, so the index layout (and every
5
4
  // shared store under it) stays as it was.
6
5
 
@@ -21,6 +20,8 @@ export interface TaskFailure {
21
20
  taskId: string
22
21
  exitCode: number
23
22
  timedOut?: true
23
+ /** The input that changed while the command ran (see `TaskOutcome`). */
24
+ inputMoved?: string
24
25
  /** Plain text, bounded, secrets masked; '' when the run kept none. */
25
26
  output: string
26
27
  locations: OutputLocation[]
@@ -47,18 +48,25 @@ function failuresOf(outcomes: readonly TaskOutcome[]): StoredFailure[] {
47
48
  const isFile = fileMemo()
48
49
  const out: StoredFailure[] = []
49
50
  for (const o of outcomes) {
50
- if (o.failedOutput === undefined || isGroupTask(o.node)) continue
51
- const output = Bun.stripANSI(
52
- decodeOutputLog(o.failedOutput)
53
- .map((c) => c.text)
54
- .join(''),
55
- )
51
+ // A task failed by a moved input may have printed nothing: the input
52
+ // is still why, and an agent reads it here.
53
+ if ((o.failedOutput === undefined && o.inputMoved === undefined) || isGroupTask(o.node))
54
+ continue
55
+ const output =
56
+ o.failedOutput === undefined
57
+ ? ''
58
+ : Bun.stripANSI(
59
+ decodeOutputLog(o.failedOutput)
60
+ .map((c) => c.text)
61
+ .join(''),
62
+ )
56
63
  out.push({
57
64
  taskId: o.node.id,
58
65
  project: o.node.projectName,
59
66
  task: o.node.taskName,
60
67
  exitCode: o.exitCode,
61
68
  ...(o.timedOut === true ? { timedOut: true as const } : {}),
69
+ ...(o.inputMoved !== undefined ? { inputMoved: o.inputMoved } : {}),
62
70
  output,
63
71
  locations: fileLocations(output, o.node.projectDir, isFile),
64
72
  ...(o.sandboxFix !== undefined ? { sandboxFix: o.sandboxFix } : {}),
@@ -138,6 +146,7 @@ export function runFailures(cacheDir: string, db: Database, runId?: string): Run
138
146
  taskId,
139
147
  exitCode: r.exitCode,
140
148
  ...(r.timedOut === 1 ? { timedOut: true as const } : {}),
149
+ ...(k?.inputMoved !== undefined ? { inputMoved: k.inputMoved } : {}),
141
150
  output: k?.output ?? '',
142
151
  locations: k?.locations ?? [],
143
152
  ...(k?.sandboxFix !== undefined ? { sandboxFix: k.sandboxFix } : {}),
@@ -330,7 +330,7 @@ export async function acquireRunLock(
330
330
  opts: RunLockOptions,
331
331
  ): Promise<() => Promise<void>> {
332
332
  const lockDir = runLockPath(workspaceRoot, opts.dir)
333
- // A task's own process (`vx cache prune` in a task) would wait for the
333
+ // A task's own process (`vx cache clean` in a task) would wait for the
334
334
  // run that started it, which holds the lock until that task ends.
335
335
  const outer = process.env[VX_RUN_WORKSPACE_ENV]
336
336
  if (outer !== undefined && runLockPath(outer, opts.dir) === lockDir) {
@@ -82,7 +82,7 @@ function statusWord(o: OutcomeView): string {
82
82
  case 'cache-hit-remote':
83
83
  return 'success'
84
84
  case 'failed':
85
- return failedLabel(o.exitCode, o.timedOut, o.sandboxViolations, o.notReady)
85
+ return failedLabel(o.exitCode, o.timedOut, o.sandboxViolations, o.notReady, o.inputMoved)
86
86
  case 'skipped':
87
87
  return skippedLabel(o.blockedBy)
88
88
  case 'aborted':
@@ -144,8 +144,13 @@ const cell = escapeMarkdownCell
144
144
  export function formatRunReportMarkdown(result: RunResult): string {
145
145
  const t = tally(result.outcomes)
146
146
  const cached = t.cachedLocal + t.cachedRemote
147
+ // The terminal summary's words and counts: every task in the run counts
148
+ // (M, 2026-10-14), and a task the stop reached before it ran was never
149
+ // aborted.
150
+ const aborted = t.aborted - t.notRun
151
+ const count = t.total + aborted + t.notRun
147
152
  const parts = [
148
- `**${t.total} task${t.total === 1 ? '' : 's'}**`,
153
+ `**${count} task${count === 1 ? '' : 's'}**`,
149
154
  `${t.successful} success`,
150
155
  `${t.failed} failed`,
151
156
  // What the hits did to the disk, as the run's summary splits them.
@@ -154,15 +159,10 @@ export function formatRunReportMarkdown(result: RunResult): string {
154
159
  : '0 cached',
155
160
  ]
156
161
  if (t.skipped > 0) parts.push(`${t.skipped} skipped`)
162
+ if (aborted > 0) parts.push(`${aborted} aborted`)
163
+ if (t.notRun > 0) parts.push(`${t.notRun} not run`)
157
164
  parts.push(`${fmtDuration(t.executedMs)} total`)
158
165
  if (t.savedMs > 0) parts.push(`${fmtDuration(t.savedMs)} saved`)
159
- // The terminal summary's words: neither is in `total`, and a task the stop
160
- // reached before it ran was never aborted.
161
- const aborted = t.aborted - t.notRun
162
- const uncounted: string[] = []
163
- if (aborted > 0) uncounted.push(`${aborted} aborted`)
164
- if (t.notRun > 0) uncounted.push(`${t.notRun} not run`)
165
- if (uncounted.length > 0) parts.push(`not counted: ${uncounted.join(', ')}`)
166
166
 
167
167
  // Only a stop (a signal, `RunOptions.signal`) makes an `aborted` outcome,
168
168
  // so one is how an interrupted run tells itself from a failed one.
@@ -0,0 +1,48 @@
1
+ // A run's run-level settings, resolved once before any task: the flag
2
+ // (a RunOptions field) beats its variable, which beats `.vx/workspace`
3
+ // (src/settings.ts). A task's own `exec.timeout` / `exec.retries` is
4
+ // applied where the task runs, above all three.
5
+
6
+ import type { WorkspaceConfig } from '../config.js'
7
+ import { resolveSetting } from '../settings.js'
8
+ import { setSettleBounds, type SettleBound } from '../util/index.js'
9
+ import type { RunOptions } from './options.js'
10
+
11
+ const FROM_OPTIONS = {
12
+ concurrency: 'run.concurrency',
13
+ schedule: 'run.schedule',
14
+ timeout: 'run.timeout',
15
+ retries: 'run.retries',
16
+ continueMode: 'run.continue',
17
+ readyNotice: 'run.readyNotice',
18
+ download: 'cache.download',
19
+ } as const satisfies Partial<Record<keyof RunOptions, string>>
20
+
21
+ /**
22
+ * `options` with each run-level setting it leaves unset filled from the
23
+ * environment, then the workspace. Arms the settle bounds (`util/settle`,
24
+ * below settings) with the flag's or workspace's `run.killGrace` and
25
+ * `run.teardownTimeout`: the variable sits between them, read per call.
26
+ */
27
+ export function withRunSettings(
28
+ options: RunOptions,
29
+ workspace: WorkspaceConfig | null | undefined,
30
+ ): RunOptions {
31
+ const out: Record<string, unknown> = { ...options }
32
+ for (const [field, path] of Object.entries(FROM_OPTIONS)) {
33
+ const { value } = resolveSetting(path, workspace, options[field as keyof RunOptions])
34
+ if (value !== undefined) out[field] = value
35
+ }
36
+ const bound = (flag: number | undefined, path: string): SettleBound | undefined => {
37
+ if (flag !== undefined) return { ms: flag, from: 'flag' }
38
+ const { value } = resolveSetting(path, workspace, undefined, {})
39
+ return value === undefined ? undefined : { ms: value as number, from: 'workspace' }
40
+ }
41
+ const killGrace = bound(options.killGrace, 'run.killGrace')
42
+ const teardown = bound(options.teardownTimeout, 'run.teardownTimeout')
43
+ setSettleBounds({
44
+ ...(killGrace !== undefined ? { killGrace } : {}),
45
+ ...(teardown !== undefined ? { teardown } : {}),
46
+ })
47
+ return out as unknown as RunOptions
48
+ }