@vzn/vx 0.0.637 → 0.0.639

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 (126) hide show
  1. package/docs/caching.md +84 -28
  2. package/docs/cli.md +273 -408
  3. package/docs/execution.md +19 -44
  4. package/docs/features.md +13 -15
  5. package/docs/patterns.md +5 -7
  6. package/docs/schema.md +68 -61
  7. package/package.json +5 -5
  8. package/schemas/last.json +7 -4
  9. package/schemas/plan.json +2 -2
  10. package/skills/vx/SKILL.md +19 -6
  11. package/src/bin.ts +13 -7
  12. package/src/cache/cache.ts +60 -11
  13. package/src/cache/git-inputs.ts +6 -0
  14. package/src/cache/index.ts +10 -2
  15. package/src/cache/inputs.ts +0 -24
  16. package/src/cache/layer.ts +32 -12
  17. package/src/cache/layered-cache.ts +112 -315
  18. package/src/cache/plugin-cache.ts +240 -0
  19. package/src/cache/policy.ts +9 -0
  20. package/src/cache/remote-lane.ts +383 -0
  21. package/src/cache/run-history.ts +92 -8
  22. package/src/cache/run-store.ts +77 -0
  23. package/src/cache/schema.ts +44 -13
  24. package/src/cache/scoped-cache.ts +22 -2
  25. package/src/cli/cache.ts +33 -16
  26. package/src/cli/completions.ts +11 -7
  27. package/src/cli/foreign-flags.ts +10 -22
  28. package/src/cli/help.ts +22 -38
  29. package/src/cli/index.ts +25 -11
  30. package/src/cli/info.ts +8 -6
  31. package/src/cli/init.ts +14 -15
  32. package/src/cli/last.ts +20 -10
  33. package/src/cli/lock.ts +39 -27
  34. package/src/cli/plan-format.ts +10 -61
  35. package/src/cli/plugin-commands.ts +77 -6
  36. package/src/cli/plugin-templates.ts +1 -1
  37. package/src/cli/run.ts +31 -191
  38. package/src/cli/select.ts +18 -9
  39. package/src/cli/show.ts +72 -40
  40. package/src/cli/task-verb.ts +4 -4
  41. package/src/cli/upgrade.ts +132 -39
  42. package/src/cli/watch-cycle.ts +2 -1
  43. package/src/cli/watch-filter.ts +7 -2
  44. package/src/cli/watch-judge.ts +6 -1
  45. package/src/cli/watch.ts +73 -34
  46. package/src/cli/why.ts +14 -5
  47. package/src/cli/workspace-config.ts +2 -2
  48. package/src/config.ts +0 -6
  49. package/src/exec/executor.ts +1 -1
  50. package/src/exec/sandbox-runtime.ts +1 -1
  51. package/src/exec/sandbox-violations.ts +3 -1
  52. package/src/graph/index.ts +9 -1
  53. package/src/graph/priorities.ts +1 -1
  54. package/src/graph/scheduler.ts +12 -16
  55. package/src/graph/task-graph.ts +119 -21
  56. package/src/graph/task-status.ts +95 -0
  57. package/src/{orchestrator → history}/failure-mode.ts +20 -11
  58. package/src/{orchestrator → history}/history.ts +61 -5
  59. package/src/history/index.ts +48 -0
  60. package/src/{orchestrator → history}/metrics.ts +14 -23
  61. package/src/{orchestrator → history}/miss-reason.ts +1 -1
  62. package/src/index.ts +21 -29
  63. package/src/orchestrator/affected-tasks.ts +8 -3
  64. package/src/orchestrator/doctor.ts +2 -2
  65. package/src/orchestrator/execute-args.ts +80 -0
  66. package/src/orchestrator/execute-task.ts +47 -429
  67. package/src/orchestrator/fingerprint-watch.ts +2 -2
  68. package/src/orchestrator/index.ts +3 -74
  69. package/src/orchestrator/miss-save.ts +33 -3
  70. package/src/orchestrator/options.ts +1 -36
  71. package/src/orchestrator/output-host.ts +4 -3
  72. package/src/orchestrator/persistent-task.ts +321 -0
  73. package/src/orchestrator/persistent.ts +130 -3
  74. package/src/orchestrator/placement.ts +3 -3
  75. package/src/orchestrator/plan.ts +18 -5
  76. package/src/orchestrator/plugin-host.ts +77 -47
  77. package/src/orchestrator/plugin.ts +86 -14
  78. package/src/orchestrator/prepare.ts +74 -31
  79. package/src/orchestrator/projects.ts +3 -0
  80. package/src/orchestrator/remote-prefetch.ts +23 -6
  81. package/src/orchestrator/run-artifacts.ts +5 -177
  82. package/src/orchestrator/run-context.ts +55 -0
  83. package/src/orchestrator/run-failures.ts +15 -3
  84. package/src/orchestrator/run-records.ts +36 -4
  85. package/src/orchestrator/run-report.ts +81 -179
  86. package/src/orchestrator/run-settings.ts +1 -16
  87. package/src/orchestrator/run-stop.ts +153 -0
  88. package/src/orchestrator/run.ts +313 -555
  89. package/src/orchestrator/sandbox-fix.ts +0 -16
  90. package/src/orchestrator/signals.ts +14 -6
  91. package/src/orchestrator/stable-keys.ts +107 -3
  92. package/src/orchestrator/steps.ts +50 -0
  93. package/src/orchestrator/task-hash.ts +3 -16
  94. package/src/orchestrator/task-log-buffer.ts +1 -1
  95. package/src/orchestrator/task-process.ts +94 -0
  96. package/src/orchestrator/telemetry-host.ts +1 -1
  97. package/src/orchestrator/telemetry.ts +6 -83
  98. package/src/{orchestrator → report}/events.ts +41 -8
  99. package/src/{orchestrator → report}/forecast.ts +35 -2
  100. package/src/{orchestrator → report}/framed-output.ts +39 -34
  101. package/src/report/index.ts +36 -0
  102. package/src/{orchestrator → report}/logger.ts +166 -21
  103. package/src/{orchestrator → report}/status-line.ts +84 -7
  104. package/src/{orchestrator → report}/summary.ts +179 -63
  105. package/src/report/tally.ts +83 -0
  106. package/src/settings.ts +13 -23
  107. package/src/{orchestrator → util}/colors.ts +49 -4
  108. package/src/util/config-frame.ts +31 -2
  109. package/src/util/errors.ts +32 -0
  110. package/src/util/git-ignored.ts +143 -0
  111. package/src/util/index.ts +25 -3
  112. package/src/util/refusal-notice.ts +111 -0
  113. package/src/util/report.ts +39 -0
  114. package/src/util/settle.ts +14 -39
  115. package/src/util/tail.ts +1 -1
  116. package/src/util/ui.ts +197 -0
  117. package/src/workspace/affected.ts +20 -23
  118. package/src/workspace/dependency-rules.ts +12 -4
  119. package/src/workspace/migration.ts +87 -91
  120. package/src/workspace/workspace.ts +5 -4
  121. package/schemas/summary.json +0 -133
  122. package/src/cache/chained-cache.ts +0 -276
  123. package/src/orchestrator/tally.ts +0 -112
  124. /package/src/{orchestrator → report}/path-links.ts +0 -0
  125. /package/src/{orchestrator → report}/plain-output.ts +0 -0
  126. /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
@@ -1,13 +1,18 @@
1
1
  import type { ProjectConfig, TaskConfig, WorkspaceRules } from '../config.js'
2
2
  import {
3
3
  asTrees,
4
+ checkIgnoreSync,
5
+ ignoreProbes,
4
6
  isLiteralPattern,
7
+ listedSync,
5
8
  normalizeGlob,
6
9
  relPosix,
7
10
  splitNegations,
8
11
  staticPrefix,
9
12
  taskGlob,
13
+ refusal,
10
14
  UserError,
15
+ wholeIgnored,
11
16
  wholeSubtreePrefixes,
12
17
  } from '../util/index.js'
13
18
  import type { PackageGraph, ProjectEntry } from '../workspace/index.js'
@@ -583,7 +588,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
583
588
  // task behind it would fold no upstream either.
584
589
  if (!packageGraph.edgeSource) {
585
590
  throw new UserError(
586
- `${id}: ^${spec.task} needs project edges; add pnpm()/bun()/npm()/yarn() or a plugin that returns \`deps\``,
591
+ `${id}: ^${spec.task} needs project edges; add pnpm()/bun()/npm()/yarn() and install so its lockfile exists, or a plugin that returns \`deps\``,
587
592
  'VX_E_DEPENDS_ON',
588
593
  )
589
594
  }
@@ -969,6 +974,19 @@ function witnessed(a: string, b: string): boolean {
969
974
  if (ga.match(candidate) && gb.match(candidate)) return true
970
975
  }
971
976
  }
977
+ // Each glob filled in whole, every wildcard a name and `**` one level or
978
+ // none: `**/dist/**` beside `**/*` meets at `x/dist/x`, a path neither
979
+ // last segment builds (solid, M 2026-10-15).
980
+ for (const glob of [a, b]) {
981
+ const segs = glob.split('/')
982
+ if (segs.some((s) => /[[\]{}()!+@]/.test(s))) continue
983
+ for (const drop of [false, true]) {
984
+ const candidate = segs
985
+ .flatMap((s) => (s === '**' ? (drop ? [] : ['x']) : [s.replace(/\*+|\?/g, 'x')]))
986
+ .join('/')
987
+ if (candidate !== '' && ga.match(candidate) && gb.match(candidate)) return true
988
+ }
989
+ }
972
990
  return false
973
991
  }
974
992
 
@@ -1114,6 +1132,15 @@ function detectOutputCollisions(
1114
1132
  }
1115
1133
  }
1116
1134
 
1135
+ /**
1136
+ * What a task's `!` output entries take back (A-44). A glob of the other
1137
+ * task's that one takes back whole is a path this task never cleans or
1138
+ * captures, so the pair cannot collide there.
1139
+ */
1140
+ function takenBackOf(n: TaskNode, field: 'files' | 'workspaceFiles'): string[] {
1141
+ return splitNegations(n.config.cache?.outputs[field] ?? []).negative
1142
+ }
1143
+
1117
1144
  /**
1118
1145
  * `collide` for a root-anchored output against another project's `files`
1119
1146
  * output rebased to the root. With an edge the dependant adds to its
@@ -1132,9 +1159,12 @@ function collideAcross(
1132
1159
  exclusive: boolean,
1133
1160
  ): void {
1134
1161
  if (neverWritesLocally(ws) || neverWritesLocally(files)) return
1162
+ const wsBack = takenBackOf(ws, 'workspaceFiles')
1163
+ const filesBack = takenBackOf(files, 'files').map((g) => (rel === '' ? g : `${rel}/${g}`))
1135
1164
  for (const ga of wsGlobs) {
1136
1165
  for (const gb of rebased) {
1137
- if (!outputsOverlap(ga, gb)) continue
1166
+ if (!outputsOverlap(ga, gb) || outputTakenBack(gb, wsBack) || outputTakenBack(ga, filesBack))
1167
+ continue
1138
1168
  const head =
1139
1169
  `${ws.id} declares the output ${JSON.stringify(ga)} in cache.outputs.workspaceFiles, ` +
1140
1170
  `inside ${files.id}'s ${JSON.stringify(gb.slice(rel === '' ? 0 : rel.length + 1))} in ` +
@@ -1391,9 +1421,12 @@ function collide(
1391
1421
  exclusive: boolean,
1392
1422
  ): void {
1393
1423
  if (neverWritesLocally(a) || neverWritesLocally(b)) return
1424
+ const aBack = takenBackOf(a, field)
1425
+ const bBack = takenBackOf(b, field)
1394
1426
  for (const ga of aGlobs ?? []) {
1395
1427
  for (const gb of bGlobs ?? []) {
1396
- if (!outputsOverlap(ga, gb)) continue
1428
+ if (!outputsOverlap(ga, gb) || outputTakenBack(gb, aBack) || outputTakenBack(ga, bBack))
1429
+ continue
1397
1430
  const head =
1398
1431
  `${a.id} and ${b.id} both declare the output ${JSON.stringify(ga)}` +
1399
1432
  (ga === gb ? '' : ` / ${JSON.stringify(gb)}`) +
@@ -1407,8 +1440,19 @@ function collide(
1407
1440
  // `run.rules.exclusiveOutputs` is off (X-53). Without an edge the two run
1408
1441
  // in either order, and the refusal below stands.
1409
1442
  const [up, down] = reaches(b.id, a.id) ? [a, b] : reaches(a.id, b.id) ? [b, a] : []
1443
+ const parts = {
1444
+ headline: `${a.id} and ${b.id} both write ${ga === gb ? ga : `${ga} / ${gb}`}`,
1445
+ ...(field === 'files' ? {} : { what: ['in cache.outputs.workspaceFiles'] }),
1446
+ why: "vx clears a task's outputs before it runs, so one deletes the other's",
1447
+ }
1410
1448
  if (up !== undefined && exclusive)
1411
- throw new UserError(`${head}. ${EXCLUSIVE_OUTPUTS_FIX}`, 'VX_E_OUTPUT_OVERLAP')
1449
+ throw refusal(
1450
+ {
1451
+ ...parts,
1452
+ fix: 'give each task its own output path, or set run.rules.exclusiveOutputs: false in .vx/workspace.ts',
1453
+ },
1454
+ { code: 'VX_E_OUTPUT_OVERLAP', message: `${head}. ${EXCLUSIVE_OUTPUTS_FIX}` },
1455
+ )
1412
1456
  if (up !== undefined && down !== undefined) {
1413
1457
  const downGlobs = splitNegations(
1414
1458
  (field === 'files'
@@ -1419,9 +1463,12 @@ function collide(
1419
1463
  ;(up.outputsAddedToBy ??= []).push(...(downGlobs ?? []))
1420
1464
  return
1421
1465
  }
1422
- throw new UserError(
1423
- `${head} and the run still reports success. Give each task its own output path.`,
1424
- 'VX_E_OUTPUT_OVERLAP',
1466
+ throw refusal(
1467
+ { ...parts, fix: 'give each task its own output path' },
1468
+ {
1469
+ code: 'VX_E_OUTPUT_OVERLAP',
1470
+ message: `${head} and the run still reports success. Give each task its own output path.`,
1471
+ },
1425
1472
  )
1426
1473
  }
1427
1474
  }
@@ -1522,6 +1569,8 @@ interface Side {
1522
1569
  reads: boolean
1523
1570
  /** The project's root-relative dir for rebased `files` globs. */
1524
1571
  rel?: string
1572
+ /** The root-relative dir project-relative `globs` sit in, for git. */
1573
+ anchor?: string | undefined
1525
1574
  }
1526
1575
 
1527
1576
  /**
@@ -1555,6 +1604,7 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
1555
1604
  let projectReaders = false
1556
1605
  let rootReaders = false
1557
1606
  const memo: TakeBackMemo = { bySide: new Map(), byList: new Map() }
1607
+ const found: Found = new Map()
1558
1608
  const bucket = (name: string): Side[] => {
1559
1609
  let list = byProject.get(name)
1560
1610
  if (list === undefined) byProject.set(name, (list = []))
@@ -1566,7 +1616,8 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
1566
1616
  const outFiles = positives(cache.outputs.files)
1567
1617
  const outWs = positives(cache.outputs.workspaceFiles ?? [])
1568
1618
  if (outFiles.length > 0) {
1569
- bucket(n.projectName).push({ node: n, globs: outFiles, reads: false })
1619
+ const anchor = workspaceRoot === undefined ? undefined : relPosix(workspaceRoot, n.projectDir)
1620
+ bucket(n.projectName).push({ node: n, globs: outFiles, reads: false, anchor })
1570
1621
  filesWriters.push(n)
1571
1622
  }
1572
1623
  if (outWs.length > 0) {
@@ -1595,7 +1646,7 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
1595
1646
  (s) => s.globs,
1596
1647
  (s) => s.reads,
1597
1648
  )) {
1598
- readsOutputs(memo, sides[i]!, sides[j]!, 'files')
1649
+ readsOutputs(memo, found, sides[i]!, sides[j]!, 'files')
1599
1650
  }
1600
1651
  }
1601
1652
  }
@@ -1613,10 +1664,20 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
1613
1664
  (s) => s.globs,
1614
1665
  (s) => s.reads,
1615
1666
  )) {
1616
- readsOutputs(memo, sides[i]!, sides[j]!, 'files')
1667
+ readsOutputs(memo, found, sides[i]!, sides[j]!, 'files')
1617
1668
  }
1618
1669
  }
1619
- if (!rootReaders) return
1670
+ if (rootReaders) readsRootedOutputs(memo, found, rooted, filesWriters, workspaceRoot)
1671
+ refuseUnlessIgnored(found, workspaceRoot)
1672
+ }
1673
+
1674
+ function readsRootedOutputs(
1675
+ memo: TakeBackMemo,
1676
+ found: Found,
1677
+ rooted: Side[],
1678
+ filesWriters: readonly TaskNode[],
1679
+ workspaceRoot: string | undefined,
1680
+ ): void {
1620
1681
  if (workspaceRoot !== undefined) {
1621
1682
  for (const node of filesWriters) {
1622
1683
  const rel = relPosix(workspaceRoot, node.projectDir)
@@ -1631,8 +1692,37 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
1631
1692
  (s) => s.globs,
1632
1693
  (s) => s.reads,
1633
1694
  )) {
1634
- readsOutputs(memo, rooted[i]!, rooted[j]!, 'workspaceFiles')
1695
+ readsOutputs(memo, found, rooted[i]!, rooted[j]!, 'workspaceFiles')
1696
+ }
1697
+ }
1698
+
1699
+ /** Each overlapped output, root-relative, and the refusal it meets; the first pair found per output. */
1700
+ type Found = Map<string, () => UserError>
1701
+
1702
+ /**
1703
+ * An output git ignores is no overlap (M, 2026-10-15): a key reads only what
1704
+ * git lists, so such an output never reaches it. Asked of git once, for
1705
+ * every overlapped output together; an output git cannot speak for (no work
1706
+ * tree, a glob no few paths stand for) is refused as before. The answer
1707
+ * holds for the lock; a run's stability gate asks git again for itself.
1708
+ */
1709
+ function refuseUnlessIgnored(found: Found, workspaceRoot: string | undefined): void {
1710
+ if (found.size === 0) return
1711
+ if (workspaceRoot !== undefined) {
1712
+ const asks = [...found.keys()].map(ignoreProbes)
1713
+ const paths = asks.flatMap((a) => a?.paths ?? [])
1714
+ const ignored = checkIgnoreSync(workspaceRoot, paths)
1715
+ let listed: string[] | null | undefined
1716
+ const pass = wholeIgnored(found.keys(), ignored, (g) => {
1717
+ listed ??= listedSync(workspaceRoot, [
1718
+ ...new Set(asks.flatMap((a) => (a?.deep === true ? [a.dir] : []))),
1719
+ ])
1720
+ return listed === null || listed.some((f) => taskGlob(g).match(f))
1721
+ })
1722
+ for (const [g, refuse] of found) if (!pass.has(g)) throw refuse()
1723
+ return
1635
1724
  }
1725
+ throw found.values().next().value!()
1636
1726
  }
1637
1727
 
1638
1728
  /**
@@ -1679,6 +1769,7 @@ function takenBack(
1679
1769
 
1680
1770
  function readsOutputs(
1681
1771
  memo: TakeBackMemo,
1772
+ found: Found,
1682
1773
  x: Side,
1683
1774
  y: Side,
1684
1775
  field: 'files' | 'workspaceFiles',
@@ -1687,21 +1778,28 @@ function readsOutputs(
1687
1778
  const [reader, writer] = x.reads ? [x, y] : [y, x]
1688
1779
  const at = reader.rel === undefined || reader.rel === '' ? '' : `${reader.rel}/`
1689
1780
  for (const go of writer.globs) {
1690
- if (takenBack(memo, reader, field, go)) continue
1781
+ const rooted =
1782
+ writer.anchor === undefined || writer.anchor === '' ? go : `${writer.anchor}/${go}`
1783
+ if (found.has(rooted) || takenBack(memo, reader, field, go)) continue
1691
1784
  for (const gi of reader.globs) {
1692
1785
  if (!outputsOverlap(gi, go)) continue
1693
1786
  const shown =
1694
1787
  writer.rel === undefined || writer.rel === '' ? go : go.slice(writer.rel.length + 1)
1695
1788
  const out = at !== '' && go.startsWith(at) ? go.slice(at.length) : go
1696
- throw new UserError(
1697
- `${reader.node.id} reads ${JSON.stringify(gi.slice(at.length))} in cache.inputs.${field}, which matches ` +
1698
- `${writer.node.id}'s output ${JSON.stringify(shown)} — a task's key must not read ` +
1699
- `another task's outputs (the dependency's key already cascades through dependsOn). ` +
1700
- `Exclude it: add ${JSON.stringify(`!${out}`)} to ${reader.node.id}'s ` +
1701
- `cache.inputs.${field}, or set run.rules.upfrontKeys: false in .vx/workspace.ts to ` +
1702
- `let it wait for its producer.`,
1703
- 'VX_E_INPUT_IS_OUTPUT',
1789
+ found.set(
1790
+ rooted,
1791
+ () =>
1792
+ new UserError(
1793
+ `${reader.node.id} reads ${JSON.stringify(gi.slice(at.length))} in cache.inputs.${field}, which matches ` +
1794
+ `${writer.node.id}'s output ${JSON.stringify(shown)} — a task's key must not read ` +
1795
+ `another task's outputs (the dependency's key already cascades through dependsOn). ` +
1796
+ `Exclude it: add ${JSON.stringify(`!${out}`)} to ${reader.node.id}'s ` +
1797
+ `cache.inputs.${field}, or set run.rules.upfrontKeys: false in .vx/workspace.ts to ` +
1798
+ `let it wait for its producer.`,
1799
+ 'VX_E_INPUT_IS_OUTPUT',
1800
+ ),
1704
1801
  )
1802
+ break
1705
1803
  }
1706
1804
  }
1707
1805
  }
@@ -0,0 +1,95 @@
1
+ // A task's terminal status and what each one means: a pass, a hit, where
2
+ // its result came from. One vocabulary every reader (the run verdict, the
3
+ // logger, the history queries, telemetry, plugins) asks, so none re-lists
4
+ // the statuses.
5
+
6
+ export type TaskStatus =
7
+ | 'success'
8
+ | 'cache-hit'
9
+ | 'cache-hit-remote'
10
+ | 'failed'
11
+ | 'skipped'
12
+ // A child killed by a shutdown signal (SIGINT/SIGTERM — e.g. Ctrl-C):
13
+ // the task never finished on its own terms, so it reverts to aborted —
14
+ // excluded from the tally and the run history. It still PROPAGATES like
15
+ // a failure (see `willSkip`): the child died mid-write, so its outputs
16
+ // are partial.
17
+ | 'aborted'
18
+
19
+ /** Where a task's result came from, derived ONCE in core from the status. */
20
+ export type CacheSource = 'miss' | 'local' | 'remote' | 'none'
21
+
22
+ /**
23
+ * Map a task outcome status to its cache source. `success`/`failed` ran
24
+ * (a miss as far as the cache read is concerned); the two cache-hit
25
+ * statuses restored from local/remote; `skipped`/`aborted` never engaged
26
+ * the cache. Pure function — the single place this mapping is decided;
27
+ * `isCacheHit` below is derived from it rather than re-listing the two
28
+ * hit statuses, so there is one decision, not two that can disagree.
29
+ */
30
+ export function deriveCacheSource(status: TaskStatus): CacheSource {
31
+ switch (status) {
32
+ case 'cache-hit':
33
+ return 'local'
34
+ case 'cache-hit-remote':
35
+ return 'remote'
36
+ case 'success':
37
+ case 'failed':
38
+ return 'miss'
39
+ case 'skipped':
40
+ case 'aborted':
41
+ return 'none'
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Whether each status counts as a PASS. Written as a `Record<TaskStatus, …>`
47
+ * rather than a `new Set([...])` on purpose: a Record must name every member,
48
+ * so adding a status to the union is a COMPILE error HERE and the omission
49
+ * cannot ship. A Set of string literals has no such tripwire — it silently
50
+ * answers `false` for the new member, which is how ten hand-rolled copies of
51
+ * this vocabulary accumulated across core, cloud and the dashboard.
52
+ */
53
+ const PASSES: Record<TaskStatus, boolean> = {
54
+ success: true,
55
+ 'cache-hit': true,
56
+ 'cache-hit-remote': true,
57
+ failed: false,
58
+ skipped: false,
59
+ aborted: false,
60
+ }
61
+
62
+ /**
63
+ * Every `TaskStatus`, at runtime. Read off `PASSES`'s keys, which the
64
+ * `Record<TaskStatus, …>` type guarantees is exactly the union — so this is
65
+ * derived, not a second list that can fall behind. Exported so a consumer that
66
+ * cannot import the type (a test asserting a copy of this vocabulary in
67
+ * another package agrees) can still enumerate the real set.
68
+ */
69
+ export const TASK_STATUSES: readonly TaskStatus[] = Object.keys(PASSES) as TaskStatus[]
70
+
71
+ /**
72
+ * Did the task pass? A cache hit counts — it produced the same result without
73
+ * spending the time, which is the whole point. `skipped` and `aborted` do NOT:
74
+ * neither finished on its own terms, so neither can vouch for anything.
75
+ *
76
+ * Takes `string`, not `TaskStatus`, because most callers hold a status that
77
+ * arrived over a wire or out of a database column. An unrecognised string
78
+ * reads as NOT passing — the safe direction, since the alternative is calling
79
+ * a run green on a status this build has never heard of.
80
+ */
81
+ export function isPassStatus(status: string): boolean {
82
+ return PASSES[status as TaskStatus] === true
83
+ }
84
+
85
+ /**
86
+ * Did the task's result come out of the cache (either layer)? Derived from
87
+ * `deriveCacheSource` rather than re-listing the two hit statuses, so the two
88
+ * cannot disagree about what a hit is. Unknown strings read as not-a-hit.
89
+ */
90
+ export function isCacheHit(status: string): boolean {
91
+ // No known-status guard: an unknown string falls through the switch to
92
+ // undefined, which is neither hit source (the guard survived item 654).
93
+ const source = deriveCacheSource(status as TaskStatus)
94
+ return source === 'local' || source === 'remote'
95
+ }
@@ -4,11 +4,13 @@
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'
11
- import { isCacheHit, TASK_STATUSES } from './telemetry.js'
13
+ import { isCacheHit, TASK_STATUSES } from '../graph/index.js'
12
14
 
13
15
  // SQL hit set derived from the predicate — never a hand-typed list and never
14
16
  // a prefix LIKE (a prefix counts any status merely NAMED cache-hit-*). The
@@ -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`,
@@ -16,7 +16,7 @@ import { branchSource, EXECUTED_RUNS_SQL } from '../cache/index.js'
16
16
  import { splitTaskId } from '../util/index.js'
17
17
  import { failureModeOf, mixedOutcomeKeysSql } from './failure-mode.js'
18
18
  import type { FailureMode } from './failure-mode.js'
19
- import { isPassStatus, TASK_STATUSES } from './telemetry.js'
19
+ import { isPassStatus, TASK_STATUSES } from '../graph/index.js'
20
20
 
21
21
  const DEFAULT_RECENT = 50
22
22
 
@@ -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
 
@@ -0,0 +1,48 @@
1
+ // What runs leave behind, read back: the duration table a schedule learns
2
+ // from, the flakiness verdict, the queries behind `vx last` / `vx why`, and
3
+ // what a miss's key changed. Read-only SQL over the cache's database; it
4
+ // never runs, renders or writes a run.
5
+ export {
6
+ EmptyHistoryProvider,
7
+ type HistoryProvider,
8
+ type HistoryTable,
9
+ LocalHistoryProvider,
10
+ PASS_STATUSES,
11
+ type TaskHistory,
12
+ type TaskResources,
13
+ } from './history.js'
14
+ export {
15
+ detectFlaky,
16
+ type FailureMode,
17
+ type FlakyCandidate,
18
+ type FlakyFinding,
19
+ type FlakyTask,
20
+ flakyTasks,
21
+ } from './failure-mode.js'
22
+ export {
23
+ cacheKeyDiff,
24
+ explainCacheKey,
25
+ getInvocation,
26
+ getRun,
27
+ inputWriters,
28
+ latestRunId,
29
+ listInvocations,
30
+ listRuns,
31
+ rootCauses,
32
+ whyDidThisRerun,
33
+ } from './metrics.js'
34
+ export type {
35
+ CacheEntryRow,
36
+ CacheKeyDiff,
37
+ CacheKeyExplanation,
38
+ InputDiffEntry,
39
+ InputWriter,
40
+ InvocationDetail,
41
+ ListInvocationsArgs,
42
+ ListRunsArgs,
43
+ RootCause,
44
+ RunDetail,
45
+ RunSummaryRow,
46
+ WhyDidThisRerun,
47
+ } from './metrics.js'
48
+ export { createMissExplainer, type MissExplainer } from './miss-reason.js'