@vzn/vx 0.0.637 → 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 (76) hide show
  1. package/docs/caching.md +41 -14
  2. package/docs/cli.md +115 -73
  3. package/docs/execution.md +1 -1
  4. package/docs/features.md +6 -5
  5. package/docs/schema.md +24 -15
  6. package/package.json +5 -5
  7. package/schemas/plan.json +2 -2
  8. package/skills/vx/SKILL.md +13 -2
  9. package/src/bin.ts +13 -7
  10. package/src/cache/cache.ts +21 -2
  11. package/src/cache/chained-cache.ts +4 -0
  12. package/src/cache/index.ts +9 -1
  13. package/src/cache/inputs.ts +0 -24
  14. package/src/cache/layer.ts +7 -1
  15. package/src/cache/layered-cache.ts +4 -0
  16. package/src/cache/plugin-cache.ts +248 -0
  17. package/src/cache/policy.ts +9 -0
  18. package/src/cache/run-history.ts +68 -0
  19. package/src/cache/run-store.ts +77 -0
  20. package/src/cache/schema.ts +38 -9
  21. package/src/cache/scoped-cache.ts +15 -2
  22. package/src/cli/cache.ts +33 -16
  23. package/src/cli/completions.ts +9 -5
  24. package/src/cli/help.ts +16 -7
  25. package/src/cli/index.ts +25 -11
  26. package/src/cli/info.ts +6 -5
  27. package/src/cli/init.ts +14 -15
  28. package/src/cli/last.ts +14 -7
  29. package/src/cli/lock.ts +39 -27
  30. package/src/cli/plan-format.ts +5 -3
  31. package/src/cli/plugin-commands.ts +77 -6
  32. package/src/cli/plugin-templates.ts +1 -1
  33. package/src/cli/run.ts +14 -12
  34. package/src/cli/select.ts +18 -9
  35. package/src/cli/show.ts +66 -40
  36. package/src/cli/task-verb.ts +4 -4
  37. package/src/cli/upgrade.ts +20 -11
  38. package/src/cli/watch-filter.ts +7 -2
  39. package/src/cli/watch-judge.ts +6 -1
  40. package/src/cli/watch.ts +44 -20
  41. package/src/cli/why.ts +10 -1
  42. package/src/cli/workspace-config.ts +2 -2
  43. package/src/graph/scheduler.ts +8 -0
  44. package/src/graph/task-graph.ts +32 -4
  45. package/src/index.ts +11 -1
  46. package/src/orchestrator/affected-tasks.ts +8 -3
  47. package/src/orchestrator/doctor.ts +1 -1
  48. package/src/orchestrator/execute-task.ts +26 -43
  49. package/src/orchestrator/failure-mode.ts +19 -10
  50. package/src/orchestrator/framed-output.ts +1 -2
  51. package/src/orchestrator/history.ts +60 -4
  52. package/src/orchestrator/index.ts +2 -1
  53. package/src/orchestrator/logger.ts +9 -2
  54. package/src/orchestrator/metrics.ts +7 -3
  55. package/src/orchestrator/plugin-host.ts +14 -4
  56. package/src/orchestrator/plugin.ts +80 -8
  57. package/src/orchestrator/prepare.ts +51 -7
  58. package/src/orchestrator/projects.ts +3 -0
  59. package/src/orchestrator/remote-prefetch.ts +9 -5
  60. package/src/orchestrator/run-records.ts +1 -1
  61. package/src/orchestrator/run.ts +126 -27
  62. package/src/orchestrator/status-line.ts +1 -1
  63. package/src/orchestrator/summary.ts +69 -44
  64. package/src/orchestrator/telemetry.ts +1 -1
  65. package/src/settings.ts +12 -1
  66. package/src/{orchestrator → util}/colors.ts +5 -0
  67. package/src/util/config-frame.ts +31 -2
  68. package/src/util/errors.ts +32 -0
  69. package/src/util/index.ts +8 -1
  70. package/src/util/refusal-notice.ts +111 -0
  71. package/src/util/report.ts +31 -0
  72. package/src/util/ui.ts +186 -0
  73. package/src/workspace/affected.ts +20 -23
  74. package/src/workspace/dependency-rules.ts +12 -4
  75. package/src/workspace/migration.ts +87 -91
  76. package/src/workspace/workspace.ts +5 -4
@@ -7,6 +7,7 @@ import {
7
7
  splitNegations,
8
8
  staticPrefix,
9
9
  taskGlob,
10
+ refusal,
10
11
  UserError,
11
12
  wholeSubtreePrefixes,
12
13
  } from '../util/index.js'
@@ -969,6 +970,19 @@ function witnessed(a: string, b: string): boolean {
969
970
  if (ga.match(candidate) && gb.match(candidate)) return true
970
971
  }
971
972
  }
973
+ // Each glob filled in whole, every wildcard a name and `**` one level or
974
+ // none: `**/dist/**` beside `**/*` meets at `x/dist/x`, a path neither
975
+ // last segment builds (solid, M 2026-10-15).
976
+ for (const glob of [a, b]) {
977
+ const segs = glob.split('/')
978
+ if (segs.some((s) => /[[\]{}()!+@]/.test(s))) continue
979
+ for (const drop of [false, true]) {
980
+ const candidate = segs
981
+ .flatMap((s) => (s === '**' ? (drop ? [] : ['x']) : [s.replace(/\*+|\?/g, 'x')]))
982
+ .join('/')
983
+ if (candidate !== '' && ga.match(candidate) && gb.match(candidate)) return true
984
+ }
985
+ }
972
986
  return false
973
987
  }
974
988
 
@@ -1407,8 +1421,19 @@ function collide(
1407
1421
  // `run.rules.exclusiveOutputs` is off (X-53). Without an edge the two run
1408
1422
  // in either order, and the refusal below stands.
1409
1423
  const [up, down] = reaches(b.id, a.id) ? [a, b] : reaches(a.id, b.id) ? [b, a] : []
1424
+ const parts = {
1425
+ headline: `${a.id} and ${b.id} both write ${ga === gb ? ga : `${ga} / ${gb}`}`,
1426
+ ...(field === 'files' ? {} : { what: ['in cache.outputs.workspaceFiles'] }),
1427
+ why: "vx clears a task's outputs before it runs, so one deletes the other's",
1428
+ }
1410
1429
  if (up !== undefined && exclusive)
1411
- throw new UserError(`${head}. ${EXCLUSIVE_OUTPUTS_FIX}`, 'VX_E_OUTPUT_OVERLAP')
1430
+ throw refusal(
1431
+ {
1432
+ ...parts,
1433
+ fix: 'give each task its own output path, or set run.rules.exclusiveOutputs: false in .vx/workspace.ts',
1434
+ },
1435
+ { code: 'VX_E_OUTPUT_OVERLAP', message: `${head}. ${EXCLUSIVE_OUTPUTS_FIX}` },
1436
+ )
1412
1437
  if (up !== undefined && down !== undefined) {
1413
1438
  const downGlobs = splitNegations(
1414
1439
  (field === 'files'
@@ -1419,9 +1444,12 @@ function collide(
1419
1444
  ;(up.outputsAddedToBy ??= []).push(...(downGlobs ?? []))
1420
1445
  return
1421
1446
  }
1422
- throw new UserError(
1423
- `${head} and the run still reports success. Give each task its own output path.`,
1424
- 'VX_E_OUTPUT_OVERLAP',
1447
+ throw refusal(
1448
+ { ...parts, fix: 'give each task its own output path' },
1449
+ {
1450
+ code: 'VX_E_OUTPUT_OVERLAP',
1451
+ message: `${head} and the run still reports success. Give each task its own output path.`,
1452
+ },
1425
1453
  )
1426
1454
  }
1427
1455
  }
package/src/index.ts CHANGED
@@ -89,7 +89,13 @@ export type { TaskNode, TaskOutcome, TaskStatus } from './graph/index.js'
89
89
 
90
90
  // Cache classes + the layer interface (the `cache` capability's currency).
91
91
  export { Cache } from './cache/index.js'
92
- export type { CacheLayer, RemoteCacheLayer, RunRecord, InvocationRecord } from './cache/index.js'
92
+ export type {
93
+ CacheLayer,
94
+ PluginCache,
95
+ RemoteCacheLayer,
96
+ RunRecord,
97
+ InvocationRecord,
98
+ } from './cache/index.js'
93
99
 
94
100
  // Workspace discovery + the project/config catalog surface — an
95
101
  // out-of-process service/CLI needs these. `loadProjectConfig` is the RAW
@@ -118,6 +124,8 @@ export {
118
124
  quoteTsLiteral,
119
125
  taskNameProblem,
120
126
  } from './workspace/index.js'
127
+ // Output components: a plugin draws its lines with core's, so they look alike.
128
+ export { ui } from './util/index.js'
121
129
  export type {
122
130
  ApplyMigrationArgs,
123
131
  MigrationFormat,
@@ -166,6 +174,8 @@ export type {
166
174
  DiscoverContext,
167
175
  DiscoveredProject,
168
176
  PluginSetupContext,
177
+ RunReport,
178
+ RunReportTask,
169
179
  PluginHookName,
170
180
  PluginHookHandlers,
171
181
  RunStartInfo,
@@ -62,13 +62,15 @@ function wholeProjects(
62
62
  * `input`, a changed `file` is one of its declared inputs; `project`, its
63
63
  * project changed and the task reads it whole (an uncached task, a path no
64
64
  * cached task declares, or no single `file`: a lockfile claim, a manifest
65
- * edge, a config import); `package`, a `^` edge passes through `project`, a
65
+ * edge, a config import); `workspace`, a workspace-wide `file` (the
66
+ * workspace config, a root file a plugin claims) reached every task of its
67
+ * project; `package`, a `^` edge passes through `project`, a
66
68
  * changed package with no such task; `named`, asked as `pkg#task`;
67
69
  * `selected`, another filter selected its project outright. `via` is the
68
70
  * `dependsOn` chain, nearest first, to the task the change seeded.
69
71
  */
70
72
  export interface AffectedReason {
71
- kind: 'input' | 'project' | 'package' | 'named' | 'selected'
73
+ kind: 'input' | 'project' | 'workspace' | 'package' | 'named' | 'selected'
72
74
  /** Workspace-relative. */
73
75
  file?: string
74
76
  project?: string
@@ -102,7 +104,10 @@ export function affectedRoots(
102
104
  const seeds = explain === undefined ? null : new Map<string, AffectedReason>()
103
105
  const through = explain === undefined ? null : new Map<string, string>()
104
106
  const inProject = (name: string, rel: string | undefined): AffectedReason => {
105
- if (rel === undefined) return { kind: 'project' }
107
+ if (rel === undefined) {
108
+ const file = changes.wholeBy?.get(name)
109
+ return file === undefined ? { kind: 'project' } : { kind: 'workspace', file }
110
+ }
106
111
  const dir = path.relative(explain!.workspaceRoot, projects.get(name)!.dir)
107
112
  return { kind: 'project', file: dir === '' ? rel : path.posix.join(dir, rel) }
108
113
  }
@@ -384,7 +384,7 @@ function gitVersion(): string | null {
384
384
  function settingsFact(config: WorkspaceConfig | null): InfoFacts['settings'] {
385
385
  const out: InfoFacts['settings'] = {}
386
386
  for (const s of SETTINGS) {
387
- const env = process.env[envName(s.path)]
387
+ const env = s.env === false ? undefined : process.env[envName(s.path)]
388
388
  const declared = s.config === false ? undefined : atPath(config, s.path)
389
389
  out[s.path] =
390
390
  env !== undefined && env !== ''
@@ -5,7 +5,6 @@ import {
5
5
  type CacheEntry,
6
6
  type CacheLayer,
7
7
  type CachePolicy,
8
- anyUntouched,
9
8
  cleanOutputs,
10
9
  committedFiles,
11
10
  cleanWorkspaceOutputs,
@@ -189,7 +188,7 @@ export interface ExecuteArgs {
189
188
  * the env vars (above the task), `workspace` from `.vx/workspace` (below).
190
189
  */
191
190
  inputStability?: { run: InputStability; workspace?: InputStability }
192
- /** `cache.outputs.clean` for tasks without their own: its variable, else `.vx/workspace`. */
191
+ /** `cache.outputs.clean` for tasks without their own: `.vx/workspace`. */
193
192
  cleanOutputs?: boolean
194
193
  /** Per-run memo for `git ls-files` (one entry per project dir). */
195
194
  gitFilesCache?: GitFilesCache
@@ -257,6 +256,12 @@ export interface ExecuteArgs {
257
256
  * clean run's stale hit.
258
257
  */
259
258
  taintedUpstream?: boolean
259
+ /**
260
+ * The run keeps its bytes in a remote (run-store.ts): an attempt captures
261
+ * its output at the entry's bounds, and an outcome that saves no entry
262
+ * (a failure, an uncached run) carries it as `runLog`.
263
+ */
264
+ keepRunLog?: boolean
260
265
  /**
261
266
  * Whether a task has rewritten a root file a plugin claims (whose bytes
262
267
  * the claimant folded) since the run read it (fingerprint-watch.ts): past that, no key taken
@@ -977,11 +982,11 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
977
982
  let stampedBefore: ReadonlyMap<string, OutputStamp> | undefined
978
983
  let wsStampedBefore: ReadonlyMap<string, OutputStamp> | undefined
979
984
  // `cache.outputs.clean: false` leaves the outputs to the first attempt's
980
- // command, stamped, so a save can tell what it found and never wrote.
981
- // An additive task never cleans by glob anyway.
985
+ // command, and the save stores whatever they hold after it, a leftover
986
+ // included: the user opted out of the clean-tree guarantee (M,
987
+ // 2026-10-15). A retry still starts clean. An additive task never cleans
988
+ // by glob anyway.
982
989
  const keepOutputs = !additive && (cacheCfg?.outputs.clean ?? args.cleanOutputs ?? true) === false
983
- let keptBefore: ReadonlyMap<string, OutputStamp> | undefined
984
- let wsKeptBefore: ReadonlyMap<string, OutputStamp> | undefined
985
990
 
986
991
  // Cache lookup. On hit, time the user-perceived restore op
987
992
  // (clean+restore+log-replay) — that's what the framed-block footer
@@ -1148,6 +1153,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1148
1153
  exitCode: number
1149
1154
  withdrawn?: true
1150
1155
  }> {
1156
+ // The first attempt under `clean: false` finds its last outputs (tsc's `.tsbuildinfo`).
1157
+ const clean = willWrite && !deferralRequested && !(keepOutputs && attempt === 1)
1151
1158
  // A deferred task's outputs are deliberately NOT coming, so wiping the
1152
1159
  // tree would replace a stale build with nothing at all. The eligibility
1153
1160
  // gate guarantees no key in this run can see what stays behind, and the
@@ -1156,16 +1163,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1156
1163
  const endStamp = span('miss: stamp outputs')
1157
1164
  stampedBefore ??= await stampOutputs(cleanArgs)
1158
1165
  endStamp()
1159
- } else if (
1160
- keepOutputs &&
1161
- attempt === 1 &&
1162
- willWrite &&
1163
- !deferralRequested &&
1164
- outputs.length > 0
1165
- ) {
1166
- keptBefore = await stampOutputs(cleanArgs)
1167
- } else if (willWrite && !deferralRequested && outputs.length > 0) {
1168
- keptBefore = undefined
1166
+ } else if (clean && outputs.length > 0) {
1169
1167
  // Mark the wiped paths, exactly as the workspace twin below does. The
1170
1168
  // git snapshot still lists them with their committed index OIDs, and
1171
1169
  // resolveFiles SKIPS its existence probe for any path carrying a
@@ -1182,16 +1180,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1182
1180
  // glob clean deleted the upstream's files before this task, which
1183
1181
  // reads them, ran (A-43).
1184
1182
  wsStampedBefore ??= await stampWorkspaceOutputs(wsCleanArgs)
1185
- } else if (
1186
- keepOutputs &&
1187
- attempt === 1 &&
1188
- willWrite &&
1189
- !deferralRequested &&
1190
- wsOutputs.length > 0
1191
- ) {
1192
- wsKeptBefore = await stampWorkspaceOutputs(wsCleanArgs)
1193
- } else if (willWrite && !deferralRequested && wsOutputs.length > 0) {
1194
- wsKeptBefore = undefined
1183
+ } else if (clean && wsOutputs.length > 0) {
1195
1184
  // Root-anchored deletions can land in other projects' dirs; mark
1196
1185
  // them so stale per-project git snapshots can't survive the wipe.
1197
1186
  const cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
@@ -1481,9 +1470,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1481
1470
  }
1482
1471
 
1483
1472
  async function buildRequest(local: boolean): Promise<ExecuteRequest> {
1484
- const kept = willSave
1485
- ? new BoundedCapture()
1486
- : new BoundedCapture(FAILED_OUTPUT_HEAD_CHARS, FAILED_OUTPUT_TAIL_CHARS)
1473
+ const kept =
1474
+ willSave || args.keepRunLog === true
1475
+ ? new BoundedCapture()
1476
+ : new BoundedCapture(FAILED_OUTPUT_HEAD_CHARS, FAILED_OUTPUT_TAIL_CHARS)
1487
1477
  output = kept
1488
1478
  const toOut = (t: string): void => {
1489
1479
  kept.push(t)
@@ -1553,6 +1543,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1553
1543
  const stability = inputStabilityOf(args.node.config.cache, args.inputStability)
1554
1544
  let movedFails: string | undefined
1555
1545
  let own: Awaited<ReturnType<typeof ownOutputs>>
1546
+ let saved = false
1556
1547
  if (effectiveExitCode === 0 && willSave && deferralRequested) {
1557
1548
  // The outputs never landed here: no artifact, no rows. A partial local
1558
1549
  // record (a row with no artifact) is exactly the corrupt-entry shape
@@ -1580,7 +1571,6 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1580
1571
  } else if (
1581
1572
  effectiveExitCode === 0 &&
1582
1573
  willSave &&
1583
- !leftBehind() &&
1584
1574
  (await keyStillTrue()) &&
1585
1575
  (own = await ownOutputs()) !== undefined
1586
1576
  ) {
@@ -1617,6 +1607,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1617
1607
  : {}),
1618
1608
  })
1619
1609
  args.deferredSaves?.set(node.id, landed)
1610
+ saved = true
1620
1611
  } else if (cfgCacheable && !remoteOnly && !deferralRequested && args.noDependants !== true) {
1621
1612
  // Ran here and saves nothing — failed, a read-only policy, a tainted
1622
1613
  // upstream, a key that no longer held — yet it wrote what a save would
@@ -1642,18 +1633,6 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1642
1633
  }
1643
1634
  }
1644
1635
 
1645
- /**
1646
- * A file the kept outputs held that the run left as it was: saving would
1647
- * file a leftover from an older build as this run's output, so nothing is
1648
- * saved (`cache.outputs.clean: false`).
1649
- */
1650
- function leftBehind(): boolean {
1651
- return (
1652
- (keptBefore !== undefined && anyUntouched(keptBefore)) ||
1653
- (wsKeptBefore !== undefined && anyUntouched(wsKeptBefore))
1654
- )
1655
- }
1656
-
1657
1636
  /**
1658
1637
  * What an additive run saves as its own, or undefined when it removed a
1659
1638
  * file it found: no entry replays a removal (`ownOutputsSince`), so the
@@ -1771,8 +1750,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1771
1750
  : {}),
1772
1751
  ...(inputChanges !== undefined ? { inputChanges } : {}),
1773
1752
  ...(unkeyed ? { unkeyed: true as const } : {}),
1753
+ ...(saved ? { saved: true as const } : {}),
1774
1754
  ...(effectiveExitCode !== 0 && output !== undefined
1775
- ? { failedOutput: failedOutputLog(output, willSave) }
1755
+ ? { failedOutput: failedOutputLog(output, willSave || args.keepRunLog === true) }
1756
+ : {}),
1757
+ ...(args.keepRunLog === true && (effectiveExitCode !== 0 || !willSave) && output !== undefined
1758
+ ? { runLog: storedOutput() }
1776
1759
  : {}),
1777
1760
  ...(result.timedOut === true && effectiveExitCode !== 0 ? { timedOut: true as const } : {}),
1778
1761
  ...(movedFails !== undefined ? { inputMoved: movedFails } : {}),
@@ -4,7 +4,9 @@
4
4
  // drift into opposite verdicts on identical data. Both build on
5
5
  // `mixedOutcomeKeysSql` + `failureModeOf`, so the rule cannot fork again.
6
6
  // The per-run detector (`detectFlaky`) and the doctor's list (`flakyTasks`)
7
- // read the same per-key projection (`keyOutcomesSql`).
7
+ // read the same per-key projection (`keyOutcomesSql`) over the shared
8
+ // store's flaky record (`task_outcomes`, v38), so every worktree of a repo
9
+ // judges a key on what all of them saw.
8
10
 
9
11
  import type { Database } from 'bun:sqlite'
10
12
  import { KEYED_RUNS_SQL } from '../cache/index.js'
@@ -17,6 +19,9 @@ const HIT_STATUSES = `(${TASK_STATUSES.filter(isCacheHit)
17
19
  .map((s) => `'${s}'`)
18
20
  .join(', ')})`
19
21
 
22
+ /** The shared store's flaky record: what `detectFlaky` and `flakyTasks` read. */
23
+ const OUTCOMES = 'task_outcomes'
24
+
20
25
  export type FailureMode = 'stable' | 'flaky-recoverable' | 'flaky-fatal'
21
26
 
22
27
  /**
@@ -33,6 +38,10 @@ export type FailureMode = 'stable' | 'flaky-recoverable' | 'flaky-fatal'
33
38
  * projection.
34
39
  */
35
40
  function keyOutcomesSql(source: string, where = ''): string {
41
+ // The flaky record keeps only rows that observed their key (run-history.ts
42
+ // `observesKey`), so it carries none of the filter's columns.
43
+ const keyed =
44
+ source === OUTCOMES ? '1' : `${KEYED_RUNS_SQL} AND cached IS NOT 0 AND blocked_by IS NULL`
36
45
  return `SELECT project, task, hash, SUM(failed) AS failures, SUM(passed) AS passes,
37
46
  SUM(retried) AS retried_failures,
38
47
  SUM(CASE WHEN failed = 1 AND id > first_pass THEN 1 ELSE 0 END) AS relapses
@@ -41,10 +50,10 @@ function keyOutcomesSql(source: string, where = ''): string {
41
50
  FROM (SELECT project, task, hash, id,
42
51
  CASE WHEN status = 'failed' THEN 1 ELSE 0 END AS failed,
43
52
  CASE WHEN status = 'success' AND attempts > 1 THEN attempts - 1 ELSE 0 END AS retried,
44
- CASE WHEN status = 'success' OR status IN ${HIT_STATUSES} OR cache_hit = 1
53
+ CASE WHEN status = 'success' OR status IN ${HIT_STATUSES}${source === OUTCOMES ? '' : ' OR cache_hit = 1'}
45
54
  THEN 1 ELSE 0 END AS passed
46
55
  FROM ${source}
47
- WHERE ${KEYED_RUNS_SQL} AND cached IS NOT 0 AND blocked_by IS NULL${where}))
56
+ WHERE ${keyed}${where}))
48
57
  GROUP BY project, task, hash`
49
58
  }
50
59
 
@@ -136,9 +145,9 @@ const keyOf = (c: { project: string; task: string; hash: string }): string =>
136
145
  * failures is a recovery, not a flake. The counts fold this run in.
137
146
  *
138
147
  * The cost follows the run's colour. No candidate (every task a hit or a
139
- * skip): no query. A green miss: one probe of the `runs_failed` partial
140
- * index for the keys that ever failed — failures are the rare rows, so the
141
- * probe is microseconds however long the history. Only a key that DID fail
148
+ * skip): no query. A green miss: one probe of the `task_outcomes_hash`
149
+ * index for the keys that ever failed — a key holds a handful of rows, so
150
+ * the probe is microseconds however long the history. Only a key that DID fail
142
151
  * before, or a task failing now, pays the projection scan over its own
143
152
  * keys, and a run that is failing has more to look at than a scan.
144
153
  */
@@ -149,8 +158,8 @@ export function detectFlaky(db: Database, candidates: readonly FlakyCandidate[])
149
158
  chunked(passedNow, (chunk) => {
150
159
  const rows = db
151
160
  .query(
152
- `SELECT DISTINCT project, task, hash FROM runs
153
- WHERE status = 'failed' AND blocked_by IS NULL AND hash IN (${chunk.map(() => '?').join(', ')})`,
161
+ `SELECT DISTINCT project, task, hash FROM ${OUTCOMES}
162
+ WHERE status = 'failed' AND hash IN (${chunk.map(() => '?').join(', ')})`,
154
163
  )
155
164
  .all(...chunk.map((c) => c.hash)) as { project: string; task: string; hash: string }[]
156
165
  for (const r of rows) everFailed.add(keyOf(r))
@@ -162,7 +171,7 @@ export function detectFlaky(db: Database, candidates: readonly FlakyCandidate[])
162
171
  const counts = new Map<string, { passes: number; failures: number; relapses: number }>()
163
172
  chunked(suspects, (chunk) => {
164
173
  const rows = db
165
- .query(keyOutcomesSql('runs', ` AND hash IN (${chunk.map(() => '?').join(', ')})`))
174
+ .query(keyOutcomesSql(OUTCOMES, ` AND hash IN (${chunk.map(() => '?').join(', ')})`))
166
175
  .all(...chunk.map((c) => c.hash)) as {
167
176
  project: string
168
177
  task: string
@@ -212,7 +221,7 @@ export function flakyTasks(db: Database): FlakyTask[] {
212
221
  .query(
213
222
  `SELECT project, task, COUNT(*) AS keys, SUM(passes) AS passes, SUM(failures) AS failures
214
223
  FROM (SELECT project, task, passes, failures + retried_failures AS failures
215
- FROM (${keyOutcomesSql('runs')})
224
+ FROM (${keyOutcomesSql(OUTCOMES)})
216
225
  WHERE relapses > 0 OR retried_failures > 0)
217
226
  GROUP BY project, task
218
227
  ORDER BY failures DESC, passes DESC, project, task`,
@@ -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'
@@ -219,10 +219,49 @@ export class LocalHistoryProvider implements HistoryProvider {
219
219
  return this.forecasts(new Set(taskIds))
220
220
  }
221
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
+
222
260
  /**
223
261
  * Each task's duration forecast (owner, 2026-10-14): the median of its
224
- * last `FORECAST_RUNS` timings on this branch, else on any branch, else
225
- * none. The timings are the shared store's `task_durations`, so every
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
226
265
  * worktree of the repo reads them and a new one forecasts on its first
227
266
  * run. A point read per task on `(project, task, source, id)`: exact
228
267
  * however old the rows, where the runs window holds only the newest
@@ -230,9 +269,9 @@ export class LocalHistoryProvider implements HistoryProvider {
230
269
  */
231
270
  private forecasts(wanted: ReadonlySet<string>): Map<string, number> {
232
271
  const out = new Map<string, number>()
272
+ this.lackedBranchHistory = false
233
273
  if (wanted.size === 0) return out
234
- // Tiers, first answer wins. The remote cache's default-branch timings
235
- // (#3563) slot in between the two.
274
+ // Tiers, first answer wins.
236
275
  type Tier = (project: string, task: string) => Array<{ ms: number }>
237
276
  const tiers: Tier[] = []
238
277
  const newest = (where: string) =>
@@ -245,18 +284,35 @@ export class LocalHistoryProvider implements HistoryProvider {
245
284
  const q = newest('source = ?')
246
285
  tiers.push((p, t) => q.all(p, t, source) as Array<{ ms: number }>)
247
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
+ }
248
298
  const any = newest(`source LIKE 'branch:%'`)
249
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
250
302
  for (const id of wanted) {
251
303
  const [project, task] = splitTaskId(id)
304
+ let answered: Tier | undefined
252
305
  for (const tier of tiers) {
253
306
  const rows = tier(project, task)
254
307
  if (rows.length === 0) continue
255
308
  const durations = rows.map((r) => r.ms).sort((x, y) => x - y)
256
309
  out.set(id, pickPercentile(durations, 0.5))
310
+ answered = tier
257
311
  break
258
312
  }
313
+ if (answered !== own) lacked = true
259
314
  }
315
+ this.lackedBranchHistory = lacked
260
316
  return out
261
317
  }
262
318
 
@@ -37,7 +37,6 @@ export { forwardedSignal } from './signals.js'
37
37
  // `vx cache clean` waits for a run on the workspace before it evicts.
38
38
  export { acquireRunLock } from './run-lock.js'
39
39
  export { defaultLogger, resolveOutputView } from './logger.js'
40
- export { detectColors, paint, type ColorSupport } from './colors.js'
41
40
  export type { Logger, OutputView } from './logger.js'
42
41
  export type { RunPlan, PlannedTask, PlanPrediction, CacheStatus } from './plan.js'
43
42
  export {
@@ -95,6 +94,8 @@ export {
95
94
  type PluginHookHandlers,
96
95
  type PluginHookName,
97
96
  type PluginSetupContext,
97
+ type RunReport,
98
+ type RunReportTask,
98
99
  type FingerprintChange,
99
100
  type FingerprintClaim,
100
101
  type FingerprintContext,
@@ -1,6 +1,5 @@
1
1
  import type { TaskNode, TaskOutcome } from '../graph/index.js'
2
2
  import { settingEnv } from '../settings.js'
3
- import { detectColors, type ColorSupport } from './colors.js'
4
3
  import {
5
4
  formatFrameClose,
6
5
  formatFrameOpen,
@@ -21,7 +20,7 @@ import {
21
20
  type StatusStream,
22
21
  type WorkerSlot,
23
22
  } from './status-line.js'
24
- import { formatSummarySection, neverStarted, type RunContext } from './summary.js'
23
+ import { formatSummarySection, neverStarted, ranUnsaved, type RunContext } from './summary.js'
25
24
  import { isGroupTask } from '../graph/index.js'
26
25
  import {
27
26
  appendTail,
@@ -30,6 +29,8 @@ import {
30
29
  resetTail,
31
30
  tailText,
32
31
  type Tail,
32
+ detectColors,
33
+ type ColorSupport,
33
34
  } from '../util/index.js'
34
35
  import { isCacheHit } from './telemetry.js'
35
36
  import { failedLabel, outcomeWord, ranNoCache } from './events.js'
@@ -329,6 +330,8 @@ export function defaultLogger(
329
330
  let upToDate = 0
330
331
  let restoredLocal = 0
331
332
  let restoredRemote = 0
333
+ let saved = 0
334
+ let notSaved = 0
332
335
  let skippedCount = 0
333
336
  let savedMs = 0
334
337
  // Cache-miss duration spread, accumulated incrementally (the same
@@ -445,6 +448,8 @@ export function defaultLogger(
445
448
  upToDate,
446
449
  restoredLocal,
447
450
  restoredRemote,
451
+ saved,
452
+ notSaved,
448
453
  miss: succeeded + failed - noCache,
449
454
  noCache,
450
455
  savedMs,
@@ -836,6 +841,8 @@ export function defaultLogger(
836
841
  case 'success':
837
842
  succeeded++
838
843
  if (ranNoCache(outcome)) noCache++
844
+ if (outcome.saved === true) saved++
845
+ else if (ranUnsaved(outcome)) notSaved++
839
846
  break
840
847
  case 'cache-hit':
841
848
  if (outcome.restored === false) upToDate++
@@ -21,6 +21,10 @@ import { clampInt, MASKED, secretNamed } from '../util/index.js'
21
21
  import type { TaskInputComponent } from './task-hash.js'
22
22
  import { isPassStatus } from './telemetry.js'
23
23
 
24
+ // A recorded `cache_policy` is the `--cache-policy` spec (`local:rw,remote:r`).
25
+ const readsCache = (spec: string): boolean => /(?:^|,)(?:local|remote):w?r/.test(spec)
26
+ const writesCache = (spec: string): boolean => /(?:^|,)(?:local|remote):r?w/.test(spec)
27
+
24
28
  // ---------------------------------------------------------------------------
25
29
  // Run listing + detail
26
30
  // ---------------------------------------------------------------------------
@@ -412,7 +416,7 @@ function unchangedKeyNote(
412
416
  p: string
413
417
  } | null
414
418
  )?.p
415
- if (policy !== undefined && !policy.split(',').some((axis) => axis === 'lR' || axis === 'rR')) {
419
+ if (policy !== undefined && !readsCache(policy)) {
416
420
  return 'cache key unchanged — re-executed because this run did not read the cache (--force, or a --cache-policy without read)'
417
421
  }
418
422
  // `get` answers null, not undefined, for no row.
@@ -438,7 +442,7 @@ function unchangedKeyNote(
438
442
  const row = db
439
443
  .query('SELECT cache_policy AS p, failed_count AS f FROM invocations WHERE run_id = ?')
440
444
  .get(id) as { p: string; f: number } | null
441
- const writes = row?.p.split(',').some((axis) => axis === 'lW' || axis === 'rW') === true
445
+ const writes = row !== null && writesCache(row.p)
442
446
  return writes ? (row?.f ?? 0) : 0
443
447
  }
444
448
  const before = failedWithWrites(prev.runId)
@@ -472,7 +476,7 @@ function unchangedKeyNote(
472
476
  p: string
473
477
  } | null
474
478
  )?.p
475
- if (prevPolicy !== undefined && !prevPolicy.split(',').some((a) => a === 'lW' || a === 'rW')) {
479
+ if (prevPolicy !== undefined && !writesCache(prevPolicy)) {
476
480
  return 'cache key unchanged — the previous run on this key did not write the cache (--no-cache, or a --cache-policy without write), so there was nothing to hit'
477
481
  }
478
482
  return 'cache key unchanged — the previous run on this key executed but no entry for it is in the cache (its save failed, or it was pruned since), so there was nothing to hit'