@vzn/vx 0.0.636 → 0.0.638

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 (151) hide show
  1. package/README.md +39 -11
  2. package/docs/caching.md +246 -213
  3. package/docs/cli.md +791 -705
  4. package/docs/execution.md +68 -76
  5. package/docs/features.md +47 -42
  6. package/docs/patterns.md +26 -25
  7. package/docs/schema.md +392 -205
  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 +4 -4
  16. package/schemas/show.json +5 -3
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +28 -13
  19. package/src/bin.ts +30 -9
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +124 -53
  22. package/src/cache/chained-cache.ts +4 -0
  23. package/src/cache/git-inputs.ts +277 -63
  24. package/src/cache/index.ts +15 -1
  25. package/src/cache/inputs.ts +102 -36
  26. package/src/cache/key-fold.ts +8 -7
  27. package/src/cache/layer.ts +15 -18
  28. package/src/cache/layered-cache.ts +20 -102
  29. package/src/cache/plugin-cache.ts +248 -0
  30. package/src/cache/policy.ts +32 -27
  31. package/src/cache/run-history.ts +104 -14
  32. package/src/cache/run-store.ts +77 -0
  33. package/src/cache/schema.ts +58 -31
  34. package/src/cache/scoped-cache.ts +237 -0
  35. package/src/cli/cache.ts +173 -34
  36. package/src/cli/completions.ts +11 -7
  37. package/src/cli/core-alias.ts +1 -1
  38. package/src/cli/foreign-flags.ts +13 -12
  39. package/src/cli/help.ts +110 -70
  40. package/src/cli/index.ts +67 -24
  41. package/src/cli/info.ts +43 -27
  42. package/src/cli/init.ts +115 -407
  43. package/src/cli/last.ts +157 -9
  44. package/src/cli/lock.ts +356 -122
  45. package/src/cli/plan-format.ts +7 -5
  46. package/src/cli/plugin-commands.ts +81 -14
  47. package/src/cli/plugin-templates.ts +11 -11
  48. package/src/cli/run.ts +116 -160
  49. package/src/cli/select.ts +50 -47
  50. package/src/cli/show.ts +83 -59
  51. package/src/cli/task-verb.ts +6 -13
  52. package/src/cli/upgrade.ts +415 -149
  53. package/src/cli/watch-cycle.ts +9 -2
  54. package/src/cli/watch-filter.ts +39 -61
  55. package/src/cli/watch-fs.ts +29 -16
  56. package/src/cli/watch-judge.ts +18 -4
  57. package/src/cli/watch-set.ts +16 -42
  58. package/src/cli/watch.ts +227 -230
  59. package/src/cli/why.ts +38 -18
  60. package/src/cli/workspace-config.ts +33 -70
  61. package/src/config.ts +169 -70
  62. package/src/exec/env.ts +1 -31
  63. package/src/exec/executor.ts +1 -5
  64. package/src/exec/kill-tree.ts +2 -1
  65. package/src/exec/sandbox-runtime.ts +12 -1
  66. package/src/exec/sandbox-violations.ts +9 -1
  67. package/src/graph/dependency-spec.ts +1 -1
  68. package/src/graph/index.ts +1 -0
  69. package/src/graph/priorities.ts +1 -1
  70. package/src/graph/scheduler.ts +27 -7
  71. package/src/graph/task-graph.ts +76 -24
  72. package/src/handoff.ts +169 -0
  73. package/src/index.ts +35 -58
  74. package/src/orchestrator/affected-tasks.ts +15 -8
  75. package/src/orchestrator/command-windows.ts +44 -0
  76. package/src/orchestrator/deferred-outputs.ts +12 -10
  77. package/src/orchestrator/doctor.ts +98 -110
  78. package/src/orchestrator/download-policy.ts +3 -3
  79. package/src/orchestrator/events.ts +19 -3
  80. package/src/orchestrator/excluded-keys.ts +0 -2
  81. package/src/orchestrator/execute-task.ts +147 -69
  82. package/src/orchestrator/failure-mode.ts +19 -10
  83. package/src/orchestrator/fingerprint-watch.ts +18 -15
  84. package/src/orchestrator/forecast.ts +10 -2
  85. package/src/orchestrator/framed-output.ts +9 -5
  86. package/src/orchestrator/graph-stage.ts +5 -5
  87. package/src/orchestrator/history.ts +126 -30
  88. package/src/orchestrator/hit-restore.ts +15 -3
  89. package/src/orchestrator/index.ts +20 -5
  90. package/src/orchestrator/local-shortcircuit.ts +2 -1
  91. package/src/orchestrator/lockfile-claim.ts +79 -20
  92. package/src/orchestrator/logger.ts +80 -41
  93. package/src/orchestrator/metrics.ts +78 -9
  94. package/src/orchestrator/miss-save.ts +3 -0
  95. package/src/orchestrator/options.ts +42 -29
  96. package/src/orchestrator/output-host.ts +72 -0
  97. package/src/orchestrator/plan.ts +6 -8
  98. package/src/orchestrator/plugin-host.ts +100 -66
  99. package/src/orchestrator/plugin.ts +173 -67
  100. package/src/orchestrator/prepare.ts +163 -78
  101. package/src/orchestrator/projects.ts +105 -99
  102. package/src/orchestrator/remote-prefetch.ts +11 -6
  103. package/src/orchestrator/run-context.ts +25 -6
  104. package/src/orchestrator/run-failures.ts +17 -8
  105. package/src/orchestrator/run-lock.ts +1 -1
  106. package/src/orchestrator/run-records.ts +1 -1
  107. package/src/orchestrator/run-report.ts +9 -9
  108. package/src/orchestrator/run-settings.ts +48 -0
  109. package/src/orchestrator/run.ts +244 -119
  110. package/src/orchestrator/sandbox-fix.ts +39 -20
  111. package/src/orchestrator/sandbox-request.ts +84 -13
  112. package/src/orchestrator/signals.ts +1 -1
  113. package/src/orchestrator/stable-keys.ts +13 -11
  114. package/src/orchestrator/status-line.ts +49 -7
  115. package/src/orchestrator/summary.ts +115 -106
  116. package/src/orchestrator/task-hash.ts +12 -83
  117. package/src/orchestrator/telemetry.ts +4 -4
  118. package/src/settings.ts +428 -0
  119. package/src/{orchestrator → util}/colors.ts +5 -0
  120. package/src/util/config-frame.ts +31 -2
  121. package/src/util/errors.ts +33 -1
  122. package/src/util/essential-env.ts +31 -0
  123. package/src/util/hide-env.ts +107 -0
  124. package/src/util/index.ts +16 -3
  125. package/src/util/paths.ts +10 -0
  126. package/src/util/refusal-notice.ts +111 -0
  127. package/src/util/report.ts +31 -0
  128. package/src/util/settle.ts +49 -30
  129. package/src/util/size.ts +23 -4
  130. package/src/util/timing.ts +3 -2
  131. package/src/util/ui.ts +186 -0
  132. package/src/util/ulid.ts +1 -1
  133. package/src/util/verbs.ts +0 -15
  134. package/src/workspace/affected.ts +84 -232
  135. package/src/workspace/config-eval.ts +15 -20
  136. package/src/workspace/config-imports.ts +78 -3
  137. package/src/workspace/config-schema.ts +209 -268
  138. package/src/workspace/dependency-rules.ts +65 -0
  139. package/src/workspace/filter.ts +2 -2
  140. package/src/workspace/fingerprint.ts +11 -121
  141. package/src/workspace/index.ts +20 -27
  142. package/src/workspace/load-reads.ts +4 -4
  143. package/src/workspace/lockfile.ts +96 -140
  144. package/src/workspace/migration.ts +307 -215
  145. package/src/workspace/package-graph.ts +30 -216
  146. package/src/workspace/project-loader.ts +80 -285
  147. package/src/workspace/repo-id.ts +2 -1
  148. package/src/workspace/workspace.ts +363 -831
  149. package/src/cache/config-evals.ts +0 -130
  150. package/src/workspace/config-cache.ts +0 -661
  151. package/src/workspace/migrate-scripts.ts +0 -1121
@@ -1,18 +1,17 @@
1
- // The workspace fingerprint is read once per run, before any task, and
2
- // every key folds it (and a lockfile plugin's per-project parts, taken from
3
- // the same files). A task in the root project may rewrite one of those
4
- // files — `pnpm install` without `--frozen-lockfile` — and every key taken
1
+ // The files the plugins claim (`VxPlugin.fingerprint`) are read once per
2
+ // run, before any task, and every key folds what their claimant takes
3
+ // from them (a lockfile plugin's per-project parts). A task in the root
4
+ // project may rewrite one of those files — `pnpm install` without `--frozen-lockfile` — and every key taken
5
5
  // before it then names a lockfile the tree no longer holds: a save under
6
6
  // one filed bytes built against the new install under the old lockfile's
7
7
  // key, replayed whenever the tree went back (item 750). The watch answers
8
- // "has a fingerprinted file moved since the run read it", re-learning the
8
+ // "has a claimed file moved since the run read it", re-learning the
9
9
  // fact only after a task that may have written one ran (`wrote`), so a run
10
10
  // with no such task never touches the files again.
11
11
 
12
12
  import { readFileSync, statSync } from 'node:fs'
13
13
  import path from 'node:path'
14
14
  import { FILE_HASH_RACY_MS, racyWindowMs } from '../cache/index.js'
15
- import { WORKSPACE_FINGERPRINT_FILES, type WorkspaceFingerprints } from '../workspace/index.js'
16
15
  import type { Logger } from './logger.js'
17
16
 
18
17
  export class FingerprintWatch {
@@ -20,22 +19,26 @@ export class FingerprintWatch {
20
19
  private movedFiles: string[] | undefined
21
20
  private said = false
22
21
 
23
- /** `at`: when the run started reading the files (ms since the epoch). */
22
+ /**
23
+ * `names`: the claimed files; `read`: the bytes of those that existed;
24
+ * `at`: when the run started reading them (ms since the epoch).
25
+ */
24
26
  constructor(
25
27
  private readonly workspaceRoot: string,
26
- private readonly read: WorkspaceFingerprints,
28
+ readonly names: readonly string[],
29
+ private readonly read: ReadonlyMap<string, Uint8Array>,
27
30
  private readonly at: number,
28
31
  ) {}
29
32
 
30
- /** A task that may rewrite a fingerprinted file (`mayWriteFingerprint`) ran a command. */
33
+ /** A task that may rewrite a claimed file (`mayWriteFingerprint`) ran a command. */
31
34
  wrote(): void {
32
35
  this.pending = true
33
36
  }
34
37
 
35
38
  /**
36
- * The fingerprinted files that no longer hold what the run read, or
39
+ * The claimed files that no longer hold what the run read, or
37
40
  * undefined. Once one has, the run stays moved: every key already taken
38
- * folded the old bytes. One `stat` per table entry after each writer; a
41
+ * folded the old bytes. One `stat` per claimed file after each writer; a
39
42
  * file written since the read (by ctime, with git's racy window) is read
40
43
  * and compared. The stat follows a link as both reads do: a lockfile
41
44
  * that is a symlink, rewritten through it, moves its target's ctime and
@@ -46,9 +49,9 @@ export class FingerprintWatch {
46
49
  if (this.movedFiles !== undefined || !this.pending) return this.movedFiles
47
50
  this.pending = false
48
51
  const moved: string[] = []
49
- for (const f of WORKSPACE_FINGERPRINT_FILES) {
52
+ for (const f of this.names) {
50
53
  const abs = path.join(this.workspaceRoot, f)
51
- const before = this.read.files.get(f)
54
+ const before = this.read.get(f)
52
55
  let ctimeMs: number
53
56
  try {
54
57
  ctimeMs = statSync(abs).ctimeMs
@@ -81,8 +84,8 @@ export class FingerprintWatch {
81
84
  if (this.said || this.movedFiles === undefined) return
82
85
  this.said = true
83
86
  log.status(
84
- `[vx] a task rewrote \`${this.movedFiles.join('`, `')}\` during the run, which every key ` +
85
- `folds (the workspace fingerprint): nothing keyed before it is restored or saved from here on`,
87
+ `[vx] a task rewrote \`${this.movedFiles.join('`, `')}\` during the run, which a plugin ` +
88
+ `folds into every key: nothing keyed before it is restored or saved from here on`,
86
89
  )
87
90
  }
88
91
  }
@@ -12,6 +12,8 @@ export interface LiveForecast {
12
12
  critical?: string
13
13
  /** Each running task's unfinished dependents, direct and transitive. */
14
14
  blocks: ReadonlyMap<string, number>
15
+ /** Each running task's p50, where history knows it: the row's expected duration. */
16
+ expected?: ReadonlyMap<string, number>
15
17
  }
16
18
 
17
19
  export type Forecast = (
@@ -90,7 +92,12 @@ export function createForecast(
90
92
 
91
93
  return (finished, running, nowMs) => {
92
94
  const blocks = new Map<string, number>()
93
- for (const id of running.keys()) blocks.set(id, blocked(id, finished))
95
+ const expected = new Map<string, number>()
96
+ for (const id of running.keys()) {
97
+ blocks.set(id, blocked(id, finished))
98
+ const p = cost.get(id)
99
+ if (p !== undefined) expected.set(id, p)
100
+ }
94
101
  const end = new Map<string, number>()
95
102
  // The running task each node's longest chain waits on.
96
103
  const head = new Map<string, string>()
@@ -128,12 +135,13 @@ export function createForecast(
128
135
  }
129
136
  work += own
130
137
  }
131
- if (!known || overrun) return { blocks }
138
+ if (!known || overrun) return { blocks, expected }
132
139
  const critical = chainEnd === undefined ? undefined : head.get(chainEnd)
133
140
  return {
134
141
  etaMs: Math.max(chain, work / workers),
135
142
  ...(critical !== undefined ? { critical } : {}),
136
143
  blocks,
144
+ expected,
137
145
  }
138
146
  }
139
147
  }
@@ -27,8 +27,7 @@
27
27
 
28
28
  import { withForwardArgs } from '../exec/index.js'
29
29
  import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
30
- import { formatElapsed, maskedCommand } from '../util/index.js'
31
- import { paint, type ColorSupport } from './colors.js'
30
+ import { formatElapsed, maskedCommand, paint, type ColorSupport } from '../util/index.js'
32
31
  import { neverStarted } from './summary.js'
33
32
  import { outcomeLabel, ranNoCache, skippedReason } from './events.js'
34
33
  import { fileMemo, linkPaths } from './path-links.js'
@@ -532,7 +531,8 @@ function violationSection(outcome: TaskOutcome, colors: ColorSupport): string[]
532
531
  // Red, not amber: a violation fails the task, so it reads as a failure.
533
532
  // vx's note, after the evidence: the grants that would have allowed it.
534
533
  // A read the output depends on is a key input too; the sandbox never
535
- // adds one (owner, 2026-10-09).
534
+ // adds one (owner, 2026-10-09), so the note names the reads no input
535
+ // glob matches and the user decides.
536
536
  const fix = outcome.sandboxFix
537
537
  const line = fix === undefined ? undefined : sandboxFixLine(fix)
538
538
  const dim = (text: string): string => paint('', text, colors, { dim: true })
@@ -541,8 +541,12 @@ function violationSection(outcome: TaskOutcome, colors: ColorSupport): string[]
541
541
  ? []
542
542
  : [
543
543
  ...(line !== undefined ? [dim(`fix: ${line}`)] : []),
544
- ...(fix.read !== undefined
545
- ? [dim('a read the output depends on belongs in cache.inputs.files too')]
544
+ ...(fix.inputs !== undefined
545
+ ? [
546
+ dim(
547
+ `and, if the output depends on them, cache.inputs.files: [${fix.inputs.map((p) => `'${p}'`).join(', ')}]`,
548
+ ),
549
+ ]
546
550
  : []),
547
551
  ...(fix.withheld !== undefined
548
552
  ? [dim(`credential path, grant it only on purpose: ${fix.withheld.join(', ')}`)]
@@ -2,8 +2,7 @@
2
2
  // CLI's `--affected` task selection (`vx show <task> --affected`), so both
3
3
  // select over the graph the hooks leave.
4
4
 
5
- import type { WorkspaceRules } from '../config.js'
6
- import type { TaskNode } from '../graph/index.js'
5
+ import type { GraphRules, TaskNode } from '../graph/index.js'
7
6
  import { type ProjectMeta, validateProjectConfig } from '../workspace/index.js'
8
7
  import { applyGraphHooks } from './plugin-host.js'
9
8
  import type { VxPlugin } from './plugin.js'
@@ -17,7 +16,7 @@ export async function applyGraphStage(
17
16
  cacheDir: string
18
17
  projectMetas: readonly ProjectMeta[]
19
18
  warn: (message: string) => void
20
- rules?: WorkspaceRules | undefined
19
+ rules?: GraphRules | undefined
21
20
  },
22
21
  ): Promise<void> {
23
22
  // A hook edits each node's config in place, as `project` does, so the
@@ -36,10 +35,11 @@ export async function applyGraphStage(
36
35
  (plugin) => {
37
36
  for (const n of nodes.values()) {
38
37
  if (isDefaultBuild(n.config)) continue
39
- const where = configPaths.get(n.projectName) ?? `${n.projectName} (no config file)`
38
+ const own = configPaths.get(n.projectName) ?? null
40
39
  validateProjectConfig(
41
40
  { tasks: { [n.taskName]: n.config } },
42
- `${where} (after plugin '${plugin.name}')`,
41
+ `${own ?? `${n.projectName} (no config file)`} (after plugin '${plugin.name}')`,
42
+ own,
43
43
  )
44
44
  }
45
45
  },
@@ -12,13 +12,17 @@
12
12
  // (zero-config); the interface is the seam a plugin fills for any other.
13
13
 
14
14
  import type { Database } from 'bun:sqlite'
15
- import { EXECUTED_RUNS_SQL } from '../cache/index.js'
15
+ import { branchSource, EXECUTED_RUNS_SQL } from '../cache/index.js'
16
+ import { splitTaskId } from '../util/index.js'
16
17
  import { failureModeOf, mixedOutcomeKeysSql } from './failure-mode.js'
17
18
  import type { FailureMode } from './failure-mode.js'
18
19
  import { isPassStatus, TASK_STATUSES } from './telemetry.js'
19
20
 
20
21
  const DEFAULT_RECENT = 50
21
22
 
23
+ /** A task's forecast is the median of this many of its newest timings. */
24
+ const FORECAST_RUNS = 5
25
+
22
26
  // A hit is recorded as `cache-hit` / `cache-hit-remote` and replays a
23
27
  // success; the list is derived so a new status cannot miss it.
24
28
  /** The pass set as SQL, `(…)`: one definition, `isPassStatus`. */
@@ -86,7 +90,9 @@ export class EmptyHistoryProvider implements HistoryProvider {
86
90
  export class LocalHistoryProvider implements HistoryProvider {
87
91
  constructor(
88
92
  private readonly db: Database,
89
- private readonly recent: number = DEFAULT_RECENT,
93
+ readonly recent: number = DEFAULT_RECENT,
94
+ /** The run's branch (`currentBranch`); undefined: a forecast reads every branch's timings. */
95
+ private readonly branch?: string | null,
90
96
  ) {}
91
97
 
92
98
  /**
@@ -107,8 +113,17 @@ export class LocalHistoryProvider implements HistoryProvider {
107
113
  * read by `run_id` and stay exact.
108
114
  */
109
115
  async loadFor(taskIds: readonly string[]): Promise<HistoryTable> {
116
+ if (taskIds.length === 0) return new Map()
117
+ return this.load(new Set(taskIds))
118
+ }
119
+
120
+ /** Every task the window holds, as `loadFor` answers each (`vx last --stats`). */
121
+ async loadAll(): Promise<HistoryTable> {
122
+ return this.load(null)
123
+ }
124
+
125
+ private load(wanted: ReadonlySet<string> | null): HistoryTable {
110
126
  const out = new Map<string, TaskHistory>()
111
- if (taskIds.length === 0) return out
112
127
  const floor = this.floor()
113
128
 
114
129
  // `skipped` rows are excluded: a skip is a task the run never executed,
@@ -160,15 +175,15 @@ export class LocalHistoryProvider implements HistoryProvider {
160
175
  // Every pair in the slice is grouped (the slice is bounded, so this is
161
176
  // cheap) and the asked-for ones are picked out here — a per-row IN-list
162
177
  // probe in SQL cost more than the grouping it would have saved.
163
- const wanted = new Set(taskIds)
164
- const rows = (this.db.query(sql).all(floor) as Row[]).filter((r) =>
165
- wanted.has(`${r.project}#${r.task}`),
178
+ const rows = (this.db.query(sql).all(floor) as Row[]).filter(
179
+ (r) => wanted === null || wanted.has(`${r.project}#${r.task}`),
166
180
  )
167
181
 
168
182
  // The flakiness signal needs per-key outcomes, which the aggregate above
169
183
  // folded away — one more pass over the slice, only for the pairs whose
170
184
  // verdict actually depends on it (failed, and no retry already proving
171
185
  // nondeterminism); a green history never pays for it.
186
+ const p50s = this.forecasts(new Set(rows.map((r) => `${r.project}#${r.task}`)))
172
187
  const mixed = this.mixedOutcomeKeys(
173
188
  floor,
174
189
  rows.filter((r) => r.failures > 0 && r.retried === 0),
@@ -182,7 +197,7 @@ export class LocalHistoryProvider implements HistoryProvider {
182
197
  if (durations !== undefined) durations.sort((a, b) => a - b)
183
198
  out.set(key, {
184
199
  runs: total,
185
- p50DurationMs: durations ? pickPercentile(durations, 0.5) : undefined,
200
+ p50DurationMs: p50s.get(key),
186
201
  p99DurationMs: durations ? pickPercentile(durations, 0.99) : undefined,
187
202
  successRate: row.successes / total,
188
203
  hitRate: row.hits / total,
@@ -195,28 +210,109 @@ export class LocalHistoryProvider implements HistoryProvider {
195
210
  }
196
211
 
197
212
  /**
198
- * The same window's p50s alone. `loadFor`'s rates and resource maxima
199
- * read every row of the window and join each hit to its entry: 38 ms at
200
- * 27,000 rows against 4 for the executed successes' durations alone.
213
+ * The forecasts alone. `loadFor`'s rates and resource maxima read every
214
+ * row of the window and join each hit to its entry: 38 ms at 27,000 rows
215
+ * against 4 for the executed successes' durations alone.
201
216
  */
202
217
  async p50sFor(taskIds: readonly string[]): Promise<ReadonlyMap<string, number>> {
218
+ if (taskIds.length === 0) return new Map()
219
+ return this.forecasts(new Set(taskIds))
220
+ }
221
+
222
+ /**
223
+ * Whether the last forecast read found some task with no timing on this
224
+ * branch (on any branch when the provider has none): what a
225
+ * default-branch import is for. Learned by the read itself, so asking
226
+ * costs nothing (a query per task was 70 ms at 8,002 tasks).
227
+ */
228
+ lackedBranchHistory = false
229
+
230
+ /** Whether timings of default-branch commit `sha` were imported. */
231
+ hasImported(sha: string): boolean {
232
+ return (
233
+ this.db
234
+ .query(`SELECT 1 FROM task_durations WHERE source = 'remote' AND sha = ? LIMIT 1`)
235
+ .get(sha) !== null
236
+ )
237
+ }
238
+
239
+ /**
240
+ * Keep default-branch commit `sha`'s timings (task id → ms) as forecast
241
+ * rows: one per task, so a repeat import, by this worktree or another
242
+ * at once, adds nothing.
243
+ */
244
+ importTimings(sha: string, timings: ReadonlyMap<string, number>): void {
245
+ const insert = this.db.query(
246
+ `INSERT OR IGNORE INTO task_durations(project, task, source, sha, duration_ms, recorded_at)
247
+ VALUES (?, ?, 'remote', ?, ?, ?)`,
248
+ )
249
+ const now = Date.now()
250
+ this.db
251
+ .transaction(() => {
252
+ for (const [id, ms] of timings) {
253
+ const [project, task] = splitTaskId(id)
254
+ insert.run(project, task, sha, Math.round(ms), now)
255
+ }
256
+ })
257
+ .immediate()
258
+ }
259
+
260
+ /**
261
+ * Each task's duration forecast (owner, 2026-10-14): the median of its
262
+ * last `FORECAST_RUNS` timings on this branch, else the default branch's
263
+ * newest imported timing (`importTimings`), else the median on any
264
+ * branch, else none. The timings are the shared store's `task_durations`, so every
265
+ * worktree of the repo reads them and a new one forecasts on its first
266
+ * run. A point read per task on `(project, task, source, id)`: exact
267
+ * however old the rows, where the runs window holds only the newest
268
+ * invocations.
269
+ */
270
+ private forecasts(wanted: ReadonlySet<string>): Map<string, number> {
203
271
  const out = new Map<string, number>()
204
- if (taskIds.length === 0) return out
205
- const rows = this.db
206
- .query(
207
- `SELECT project, task, GROUP_CONCAT(duration_ms) AS ds FROM runs
208
- WHERE id >= ? AND status = 'success' AND (cache_hit IS NULL OR cache_hit = 0)
209
- GROUP BY project, task`,
272
+ this.lackedBranchHistory = false
273
+ if (wanted.size === 0) return out
274
+ // Tiers, first answer wins.
275
+ type Tier = (project: string, task: string) => Array<{ ms: number }>
276
+ const tiers: Tier[] = []
277
+ const newest = (where: string) =>
278
+ this.db.query(
279
+ `SELECT duration_ms AS ms FROM task_durations WHERE project = ? AND task = ? AND ${where}
280
+ ORDER BY id DESC LIMIT ${FORECAST_RUNS}`,
210
281
  )
211
- .all(this.floor()) as Array<{ project: string; task: string; ds: string }>
212
- const wanted = new Set(taskIds)
213
- for (const row of rows) {
214
- const key = `${row.project}#${row.task}`
215
- if (!wanted.has(key)) continue
216
- const durations = row.ds.split(',').map(Number)
217
- durations.sort((a, b) => a - b)
218
- out.set(key, pickPercentile(durations, 0.5))
282
+ if (this.branch !== undefined) {
283
+ const source = branchSource(this.branch)
284
+ const q = newest('source = ?')
285
+ tiers.push((p, t) => q.all(p, t, source) as Array<{ ms: number }>)
286
+ }
287
+ // One probe on the partial index first: a store with no import costs
288
+ // no query per task.
289
+ if (
290
+ this.db.query(`SELECT 1 FROM task_durations WHERE source = 'remote' LIMIT 1`).get() !== null
291
+ ) {
292
+ const remote = this.db.query(
293
+ `SELECT duration_ms AS ms FROM task_durations WHERE project = ? AND task = ? AND source = 'remote'
294
+ ORDER BY id DESC LIMIT 1`,
295
+ )
296
+ tiers.push((p, t) => remote.all(p, t) as Array<{ ms: number }>)
297
+ }
298
+ const any = newest(`source LIKE 'branch:%'`)
299
+ tiers.push((p, t) => any.all(p, t) as Array<{ ms: number }>)
300
+ const own = this.branch !== undefined ? tiers[0] : tiers.at(-1)
301
+ let lacked = false
302
+ for (const id of wanted) {
303
+ const [project, task] = splitTaskId(id)
304
+ let answered: Tier | undefined
305
+ for (const tier of tiers) {
306
+ const rows = tier(project, task)
307
+ if (rows.length === 0) continue
308
+ const durations = rows.map((r) => r.ms).sort((x, y) => x - y)
309
+ out.set(id, pickPercentile(durations, 0.5))
310
+ answered = tier
311
+ break
312
+ }
313
+ if (answered !== own) lacked = true
219
314
  }
315
+ this.lackedBranchHistory = lacked
220
316
  return out
221
317
  }
222
318
 
@@ -236,13 +332,12 @@ export class LocalHistoryProvider implements HistoryProvider {
236
332
  type Executed = {
237
333
  project: string
238
334
  task: string
239
- ds: string
240
335
  rss: number | null
241
336
  cpu: number | null
242
337
  }
243
338
  const executed = this.db
244
339
  .query(
245
- `SELECT project, task, GROUP_CONCAT(duration_ms) AS ds, MAX(peak_rss_bytes) AS rss,
340
+ `SELECT project, task, MAX(peak_rss_bytes) AS rss,
246
341
  MAX(CASE WHEN cpu_ms IS NOT NULL AND duration_ms > 0
247
342
  THEN cpu_ms * 1.0 / duration_ms END) AS cpu
248
343
  FROM runs
@@ -250,13 +345,12 @@ export class LocalHistoryProvider implements HistoryProvider {
250
345
  GROUP BY project, task`,
251
346
  )
252
347
  .all(floor) as Executed[]
348
+ const p50s = this.forecasts(wanted)
253
349
  for (const row of executed) {
254
350
  const key = `${row.project}#${row.task}`
255
351
  if (!wanted.has(key)) continue
256
- const durations = row.ds.split(',').map(Number)
257
- durations.sort((a, b) => a - b)
258
352
  out.set(key, {
259
- p50DurationMs: pickPercentile(durations, 0.5),
353
+ p50DurationMs: p50s.get(key),
260
354
  ...(row.rss !== null ? { maxPeakRssBytes: row.rss } : {}),
261
355
  ...(row.cpu !== null ? { maxCpuParallelism: row.cpu } : {}),
262
356
  })
@@ -279,11 +373,13 @@ export class LocalHistoryProvider implements HistoryProvider {
279
373
  const rss = Math.max(had?.maxPeakRssBytes ?? -Infinity, row.rss ?? -Infinity)
280
374
  const cpu = Math.max(had?.maxCpuParallelism ?? -Infinity, row.cpu ?? -Infinity)
281
375
  out.set(key, {
282
- p50DurationMs: had?.p50DurationMs,
376
+ p50DurationMs: p50s.get(key),
283
377
  ...(rss !== -Infinity ? { maxPeakRssBytes: rss } : {}),
284
378
  ...(cpu !== -Infinity ? { maxCpuParallelism: cpu } : {}),
285
379
  })
286
380
  }
381
+ // A forecast reads past the window: a task with none in it still has one.
382
+ for (const [key, p50] of p50s) if (!out.has(key)) out.set(key, { p50DurationMs: p50 })
287
383
  return out
288
384
  }
289
385
 
@@ -14,6 +14,7 @@ import {
14
14
  cleanOutputPaths,
15
15
  cleanOutputs,
16
16
  cleanWorkspaceOutputs,
17
+ committedFiles,
17
18
  resolveOutputs,
18
19
  resolveWorkspaceOutputs,
19
20
  WORKSPACE_OUTPUT_PREFIX,
@@ -67,8 +68,13 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
67
68
  projectDir: node.projectDir,
68
69
  outputs,
69
70
  nestedProjectDirs: args.nestedProjectDirs,
71
+ committed: committedFiles(args.gitFilesCache, node.projectDir),
72
+ }
73
+ const wsCleanArgs = {
74
+ workspaceRoot: args.workspaceRoot,
75
+ outputs: wsOutputs,
76
+ committed: committedFiles(args.gitFilesCache, args.workspaceRoot),
70
77
  }
71
- const wsCleanArgs = { workspaceRoot: args.workspaceRoot, outputs: wsOutputs }
72
78
  // The two sides of an overlapping pair (item 588). An ADDITIVE task owns
73
79
  // only its recorded rows: its glob also selects the upstream's files it
74
80
  // adds beside, so "no strays" is not a claim it can make, and its clean
@@ -101,7 +107,7 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
101
107
  // output replays from the row (`hit.stdout`), and its artifact holds
102
108
  // only logs. Extracting it anyway cost an `exists` + a tar read per hit
103
109
  // — 1.7 ms each on a four-task real repo whose `link` and `element`
104
- // tasks declare `outputs: []` (2026-09-10, VX_TIMING).
110
+ // tasks declare `outputs: []` (2026-09-10, `VX_DEBUG_TIMING`).
105
111
  let skipRestore = !anyOutputs
106
112
  // The walk that found the tree stale, handed to the clean when nothing
107
113
  // was awaited between them: the clean's own walk would only repeat it
@@ -230,7 +236,12 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
230
236
  let cleanedWsRels: string[] = []
231
237
  if (additive) {
232
238
  cleanedRels = hit.outputFiles.filter((p) => !p.startsWith(WORKSPACE_OUTPUT_PREFIX))
233
- await cleanOutputPaths({ projectDir: node.projectDir, rels: cleanedRels, outputs })
239
+ await cleanOutputPaths({
240
+ projectDir: node.projectDir,
241
+ rels: cleanedRels,
242
+ outputs,
243
+ committed: cleanArgs.committed,
244
+ })
234
245
  cleanedWsRels = hit.outputFiles
235
246
  .filter((p) => p.startsWith(WORKSPACE_OUTPUT_PREFIX))
236
247
  .map((p) => p.slice(WORKSPACE_OUTPUT_PREFIX.length))
@@ -238,6 +249,7 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
238
249
  projectDir: args.workspaceRoot,
239
250
  rels: cleanedWsRels,
240
251
  outputs: wsOutputs,
252
+ committed: wsCleanArgs.committed,
241
253
  })
242
254
  } else {
243
255
  if (outputs.length > 0) {
@@ -4,6 +4,7 @@
4
4
  export { run, planRun, nxProjectTarget, projectNamed } from './run.js'
5
5
  export { prepareRun, type PreparedRun } from './prepare.js'
6
6
  export { affectedTaskProjects } from './affected-tasks.js'
7
+ export { applyGraphStage } from './graph-stage.js'
7
8
  export type { AffectedReason } from './affected-tasks.js'
8
9
  export {
9
10
  discoverProjects,
@@ -13,6 +14,8 @@ export {
13
14
  loadProjects,
14
15
  loadResolvedProjects,
15
16
  loadWorkspacePlugins,
17
+ lockOfDiscovery,
18
+ lockReader,
16
19
  type LoadedProjects,
17
20
  type LoadProjectsArgs,
18
21
  } from './projects.js'
@@ -21,15 +24,19 @@ export type { HeldPersistent, RunOptions, RunSummary } from './options.js'
21
24
  // Re-surface the cache policy contract (defined in the cache module) so
22
25
  // embedders constructing RunOptions.cache and the package façade can
23
26
  // reach it without importing the cache module directly.
24
- export { type CachePolicy, FULL_CACHE_POLICY, parseCachePolicy } from '../cache/index.js'
27
+ export {
28
+ type CacheFlags,
29
+ type CachePolicy,
30
+ FULL_CACHE_POLICY,
31
+ parseCachePolicy,
32
+ } from '../cache/index.js'
25
33
  // The CLI reads a recorded exit code; the runner's convention decodes it.
26
34
  // It records its own argv quoted as the runner quotes forwarded args.
27
35
  export { exitSignal, shellQuote } from '../exec/index.js'
28
36
  export { forwardedSignal } from './signals.js'
29
- // `vx cache prune` waits for a run on the workspace before it evicts.
37
+ // `vx cache clean` waits for a run on the workspace before it evicts.
30
38
  export { acquireRunLock } from './run-lock.js'
31
39
  export { defaultLogger, resolveOutputView } from './logger.js'
32
- export { detectColors, paint, type ColorSupport } from './colors.js'
33
40
  export type { Logger, OutputView } from './logger.js'
34
41
  export type { RunPlan, PlannedTask, PlanPrediction, CacheStatus } from './plan.js'
35
42
  export {
@@ -70,6 +77,7 @@ export {
70
77
  installPlugins,
71
78
  type CacheContext,
72
79
  type ExecutorContext,
80
+ type OutputContext,
73
81
  type CommandContext,
74
82
  type GraphHookContext,
75
83
  type KeyHookContext,
@@ -79,13 +87,15 @@ export {
79
87
  type AdmitContext,
80
88
  type WorkspaceHookContext,
81
89
  type DiscoverContext,
82
- type NamedProject,
90
+ type DiscoveredProject,
83
91
  type InstallPluginsArgs,
84
92
  type Plugin,
85
93
  type PluginContext,
86
94
  type PluginHookHandlers,
87
95
  type PluginHookName,
88
96
  type PluginSetupContext,
97
+ type RunReport,
98
+ type RunReportTask,
89
99
  type FingerprintChange,
90
100
  type FingerprintClaim,
91
101
  type FingerprintContext,
@@ -105,8 +115,9 @@ export {
105
115
  claimedAffected,
106
116
  fingerprintClaims,
107
117
  hasHook,
108
- CACHE_LAYER_METHODS,
118
+ REMOTE_CACHE_METHODS,
109
119
  resolveCache,
120
+ type CacheHost,
110
121
  resolveExecutors,
111
122
  } from './plugin-host.js'
112
123
  export {
@@ -114,6 +125,7 @@ export {
114
125
  reachDigests,
115
126
  type LockfileClaimHooks,
116
127
  type LockfileClaimOptions,
128
+ type LockfileImporter,
117
129
  type ReachGraph,
118
130
  } from './lockfile-claim.js'
119
131
  export { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
@@ -147,6 +159,7 @@ export {
147
159
  captureGitContext,
148
160
  captureHostContext,
149
161
  captureWorkspaceIdentity,
162
+ currentBranch,
150
163
  detectCi,
151
164
  } from './run-context.js'
152
165
  export type { CiContext, GitContext, HostContext, WorkspaceIdentity } from './run-context.js'
@@ -158,6 +171,7 @@ export {
158
171
  latestRunId,
159
172
  listInvocations,
160
173
  listRuns,
174
+ inputWriters,
161
175
  rootCauses,
162
176
  whyDidThisRerun as whyDidThisRerunQuery,
163
177
  } from './metrics.js'
@@ -173,6 +187,7 @@ export { resolveRunId, shortRunId } from './run-id.js'
173
187
  export type {
174
188
  CacheEntryRow,
175
189
  CacheKeyDiff,
190
+ InputWriter,
176
191
  RootCause,
177
192
  CacheKeyExplanation,
178
193
  InputDiffEntry,
@@ -52,7 +52,8 @@ export interface ShortCircuitArgs {
52
52
  nodes: Map<string, TaskNode>
53
53
  cache: CacheLayer
54
54
  workspaceRoot: string
55
- workspaceFingerprint: string
55
+ /** The root files the plugins claim (`DeriveStableKeysArgs.claimedFiles`). */
56
+ claimedFiles: readonly string[]
56
57
  forwardArgs?: readonly string[] | undefined
57
58
  nestedDirsByProject: Map<string, string[]>
58
59
  gitFilesCache: GitFilesCache