@vzn/vx 0.0.526 → 0.0.528

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.526",
3
+ "version": "0.0.528",
4
4
  "description": "The fastest task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,10 +21,10 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.526",
25
- "@vzn/vx-linux-arm64": "0.0.526",
26
- "@vzn/vx-darwin-x64": "0.0.526",
27
- "@vzn/vx-darwin-arm64": "0.0.526"
24
+ "@vzn/vx-linux-x64": "0.0.528",
25
+ "@vzn/vx-linux-arm64": "0.0.528",
26
+ "@vzn/vx-darwin-x64": "0.0.528",
27
+ "@vzn/vx-darwin-arm64": "0.0.528"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -11,6 +11,7 @@ export {
11
11
  export {
12
12
  type ContinueMode,
13
13
  deadServerBehind,
14
+ type InputChanges,
14
15
  RestoreDemoted,
15
16
  runGraph,
16
17
  type TaskOutcome,
@@ -36,6 +36,14 @@ export type TaskStatus =
36
36
  // are partial.
37
37
  | 'aborted'
38
38
 
39
+ /** What moved in a missed task's key since the last entry saved for it. */
40
+ export interface InputChanges {
41
+ /** How many components changed, appeared or went. 0: the same key, its entry gone. */
42
+ count: number
43
+ /** The first of them, by kind then name. */
44
+ first: readonly { kind: string; name: string; change: 'changed' | 'added' | 'removed' }[]
45
+ }
46
+
39
47
  export interface TaskOutcome {
40
48
  node: TaskNode
41
49
  status: TaskStatus
@@ -69,6 +77,20 @@ export interface TaskOutcome {
69
77
  * stream and the summary footer show it.
70
78
  */
71
79
  admissionHeldMs?: number
80
+ /**
81
+ * How long this task waited ready (its deps done, or none to wait on)
82
+ * before it was dispatched: a full worker pool's wait, plus any
83
+ * `admissionHeldMs`. Absent when it started within the millisecond and
84
+ * on a task that never ran.
85
+ */
86
+ queuedMs?: number
87
+ /** On a cacheable task that ran: how many files its key read. */
88
+ inputFiles?: number
89
+ /**
90
+ * On a cacheable task that ran: what its key changed since the last entry
91
+ * saved for it. Only when a telemetry sink asked, and the cache holds one.
92
+ */
93
+ inputChanges?: InputChanges
72
94
  /** v11 analytics: CPU time + peak RSS for this task's child process. */
73
95
  cpuMs?: number
74
96
  peakRssBytes?: number
@@ -439,9 +461,14 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
439
461
  // decrements still happen (in finishOne) but never re-enqueue it.
440
462
  // Everything else enqueues on the exec-tier the moment its deps
441
463
  // complete.
464
+ // When each queued id became ready; dispatch takes it out.
465
+ const readyAt = new Map<string, number>()
466
+ const startedAt = Date.now()
442
467
  for (const node of nodes.values()) {
443
468
  if (restoreTier?.has(node.id)) restoreReady.push(node.id)
444
469
  else if (node.deps.length === 0) execReady.push(node.id)
470
+ else continue
471
+ readyAt.set(node.id, startedAt)
445
472
  }
446
473
 
447
474
  let active = 0
@@ -603,7 +630,10 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
603
630
  // don't wait on deps); one that finished early releases its own
604
631
  // dependents now. Only an exec-tier dependent is enqueued.
605
632
  if (heldRelease.delete(d)) release(d)
606
- else if (!inRestoreTier(d)) execReady.push(d)
633
+ else if (!inRestoreTier(d)) {
634
+ execReady.push(d)
635
+ readyAt.set(d, Date.now())
636
+ }
607
637
  }
608
638
  }
609
639
 
@@ -741,6 +771,8 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
741
771
  continue
742
772
  }
743
773
 
774
+ const queuedMs = Date.now() - (readyAt.get(id) ?? Date.now())
775
+ readyAt.delete(id)
744
776
  const leave = admit(id)
745
777
  // Listed as running on dispatch, so the policy's next ask in this
746
778
  // tick sees it; the completion callbacks unlist it.
@@ -749,7 +781,13 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
749
781
  const heldMs = since === undefined ? 0 : Math.max(1, Date.now() - since)
750
782
  if (since !== undefined) heldSince.delete(id)
751
783
  const withHold = (o: TaskOutcome): TaskOutcome =>
752
- heldMs > 0 ? { ...o, admissionHeldMs: heldMs } : o
784
+ heldMs === 0 && queuedMs === 0
785
+ ? o
786
+ : {
787
+ ...o,
788
+ ...(heldMs > 0 ? { admissionHeldMs: heldMs } : {}),
789
+ ...(queuedMs > 0 ? { queuedMs } : {}),
790
+ }
753
791
  // Crash-isolated observer hook — a throwing onStart must not abort
754
792
  // the dispatch loop (it would strand the tick with the slot held).
755
793
  // A demoted task's second dispatch is the same task, already started.
@@ -799,7 +837,10 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
799
837
  leave()
800
838
  untrack()
801
839
  demoted.add(id)
802
- if (pending.get(id) === 0) execReady.push(id)
840
+ if (pending.get(id) === 0) {
841
+ execReady.push(id)
842
+ readyAt.set(id, Date.now())
843
+ }
803
844
  tick()
804
845
  return
805
846
  }
@@ -7,7 +7,7 @@
7
7
  // stall task exec. See docs/design/event-stream-2026-06.md.
8
8
 
9
9
  import { exitSignal } from '../exec/index.js'
10
- import type { TaskNode, TaskOutcome } from '../graph/index.js'
10
+ import type { InputChanges, TaskNode, TaskOutcome } from '../graph/index.js'
11
11
  import { isGroupTask } from '../graph/index.js'
12
12
  import { maskedCommand, secretMask } from '../util/index.js'
13
13
  import type { Logger } from './logger.js'
@@ -250,6 +250,12 @@ export interface OutcomeView {
250
250
  blockedBy?: string
251
251
  /** How long an `admit` policy held the task with a worker free (see `TaskOutcome`). */
252
252
  admissionHeldMs?: number
253
+ /** How long the task waited ready for a worker (see `TaskOutcome`). */
254
+ queuedMs?: number
255
+ /** How many files a cacheable task's key read when it ran (see `TaskOutcome`). */
256
+ inputFiles?: number
257
+ /** What a missed task's key changed since its last saved entry (see `TaskOutcome`). */
258
+ inputChanges?: InputChanges
253
259
  restored?: boolean
254
260
  sandboxViolations?: number
255
261
  sandboxViolationLines?: string[]
@@ -369,6 +375,9 @@ export function projectOutcome(outcome: TaskOutcome): OutcomeView {
369
375
  if (outcome.cpuMs !== undefined) view.cpuMs = outcome.cpuMs
370
376
  if (outcome.peakRssBytes !== undefined) view.peakRssBytes = outcome.peakRssBytes
371
377
  if (outcome.admissionHeldMs !== undefined) view.admissionHeldMs = outcome.admissionHeldMs
378
+ if (outcome.queuedMs !== undefined) view.queuedMs = outcome.queuedMs
379
+ if (outcome.inputFiles !== undefined) view.inputFiles = outcome.inputFiles
380
+ if (outcome.inputChanges !== undefined) view.inputChanges = outcome.inputChanges
372
381
  if (outcome.timedOut === true) view.timedOut = true
373
382
  if (outcome.notReady !== undefined) view.notReady = outcome.notReady
374
383
  if (outcome.blockedBy !== undefined) view.blockedBy = outcome.blockedBy
@@ -68,6 +68,7 @@ import {
68
68
  import { markUnsaved, saveMiss, type OutputDirSnapshot } from './miss-save.js'
69
69
  import type { FingerprintWatch } from './fingerprint-watch.js'
70
70
  import { restoreHit } from './hit-restore.js'
71
+ import type { MissExplainer } from './miss-reason.js'
71
72
  import { shellVerdict } from './shell-verdict.js'
72
73
  // The hit path's entry stays importable from here (tests).
73
74
  export { restoreHit, type RestoreHitArgs } from './hit-restore.js'
@@ -147,6 +148,8 @@ export interface ExecuteArgs {
147
148
  liveChildren?: Set<ReturnType<typeof Bun.spawn>>
148
149
  /** Sample a spawned task's process tree (`TelemetrySource.track`); absent when nobody samples. */
149
150
  track?: (taskId: string, pid: number) => () => void
151
+ /** Name what a missed key changed (`createMissExplainer`); absent when nobody listens. */
152
+ explainMiss?: MissExplainer
150
153
  /**
151
154
  * Run-level retry default (`--retry <n>` / `RunOptions.retries`).
152
155
  * Explicit `exec.retries` wins, including an explicit 0. Threaded as
@@ -819,6 +822,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
819
822
  if (c.kind === 'env' && named.includes(c.name) && !secretNamed(c.name))
820
823
  c.hash = MASKED + c.hash
821
824
  const inputs: TaskInputs | undefined = described?.inputs
825
+ const inputChanges = described !== undefined ? args.explainMiss?.(node.id, captured) : undefined
822
826
  // A plugin may keep the request past the run (the cache closed): it gets
823
827
  // the upstream outputs read now. Only the local floor leaves them unread.
824
828
  if (inputs !== undefined && !isLocalExecutor(args.executor)) void inputs.upstream
@@ -1361,6 +1365,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1361
1365
  hash,
1362
1366
  ...(attempt > 1 ? { attempts: attempt } : {}),
1363
1367
  ...(failedAttempts.length > 0 ? { failedAttempts } : {}),
1368
+ ...(described !== undefined
1369
+ ? { inputFiles: captured.reduce((n, c) => (c.kind === 'file' ? n + 1 : n), 0) }
1370
+ : {}),
1371
+ ...(inputChanges !== undefined ? { inputChanges } : {}),
1364
1372
  ...(unkeyed ? { unkeyed: true as const } : {}),
1365
1373
  ...(result.timedOut === true && effectiveExitCode !== 0 ? { timedOut: true as const } : {}),
1366
1374
  ...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
@@ -0,0 +1,55 @@
1
+ // Why a task missed the cache, for telemetry: its key's components against
2
+ // the last entry this cache saved for the same task. The `vx why` join
3
+ // (`diffKeyComponents`), asked at run time instead of after it.
4
+ //
5
+ // Paid only when a telemetry sink exists, and only on a miss: one scan of
6
+ // `entries` for the newest entry per task, the first time a miss asks, then
7
+ // one primary-key read of that entry's `entry_inputs` per miss.
8
+
9
+ import type { Database } from 'bun:sqlite'
10
+ import type { InputChanges } from '../graph/index.js'
11
+ import { diffKeyComponents } from './metrics.js'
12
+ import type { TaskInputComponent } from './task-hash.js'
13
+
14
+ /** How many changed components a miss names; `count` says how many there were. */
15
+ const NAMED = 10
16
+
17
+ export type MissExplainer = (
18
+ taskId: string,
19
+ components: readonly TaskInputComponent[],
20
+ ) => InputChanges | undefined
21
+
22
+ export function createMissExplainer(db: Database): MissExplainer {
23
+ let newest: Map<string, string> | undefined
24
+ return (taskId, components) => {
25
+ try {
26
+ // Each task asks before its own command runs, so its own new entry is
27
+ // never the one it is compared with.
28
+ newest ??= new Map(
29
+ (
30
+ db
31
+ // A bare column beside MAX() comes from the row that has it.
32
+ .query(
33
+ 'SELECT project, task, hash, MAX(created_at) FROM entries GROUP BY project, task',
34
+ )
35
+ .all() as { project: string; task: string; hash: string }[]
36
+ ).map((r) => [`${r.project}#${r.task}`, r.hash]),
37
+ )
38
+ const prev = newest.get(taskId)
39
+ if (prev === undefined) return undefined
40
+ const before = db
41
+ .query('SELECT kind, name, hash FROM entry_inputs WHERE entry_hash = ?')
42
+ .all(prev) as TaskInputComponent[]
43
+ // Pruned, or saved before fingerprints were kept: nothing to compare.
44
+ if (before.length === 0) return undefined
45
+ const { entries } = diffKeyComponents(before, components)
46
+ return {
47
+ count: entries.length,
48
+ first: entries.slice(0, NAMED).map(({ kind, name, change }) => ({ kind, name, change })),
49
+ }
50
+ } catch {
51
+ // History is observability: an unreadable one explains nothing.
52
+ return undefined
53
+ }
54
+ }
55
+ }
@@ -29,6 +29,16 @@ export interface CiContext {
29
29
  /** Which CI matched: 'github' | 'gitlab' | 'buildkite' | 'circleci'
30
30
  * | 'generic' (bare `CI`), or null when no CI env is present. */
31
31
  provider: string | null
32
+ /** The provider's page for this run (a GitHub Actions run, a GitLab pipeline). */
33
+ runUrl?: string
34
+ /** The pull or merge request number this run builds. */
35
+ change?: string
36
+ /** The workflow or pipeline name. */
37
+ pipeline?: string
38
+ /** The job (or step) within it. */
39
+ job?: string
40
+ /** 1 on a first run, 2 on its first re-run. */
41
+ attempt?: number
32
42
  }
33
43
 
34
44
  export interface HostContext {
@@ -307,11 +317,71 @@ function isTruthy(v: string | undefined): boolean {
307
317
  */
308
318
  export function detectCi(env: NodeJS.ProcessEnv | Record<string, string | undefined>): CiContext {
309
319
  for (const [varName, provider] of CI_PROVIDERS) {
310
- if (isTruthy(env[varName])) return { ci: true, provider }
320
+ if (isTruthy(env[varName])) return { ci: true, provider, ...ciLinks(provider, env) }
311
321
  }
312
322
  return { ci: false, provider: null }
313
323
  }
314
324
 
325
+ type CiLinks = Pick<CiContext, 'runUrl' | 'change' | 'pipeline' | 'job' | 'attempt'>
326
+
327
+ /** Where a dashboard links back to: each provider's own documented variables. */
328
+ function ciLinks(provider: string, env: Record<string, string | undefined>): CiLinks {
329
+ const v = (k: string): string | undefined => (env[k] === '' ? undefined : env[k])
330
+ const n = (raw: string | undefined, plus = 0): number | undefined => {
331
+ const x = raw === undefined ? NaN : Number(raw)
332
+ return Number.isInteger(x) && x >= 0 ? x + plus : undefined
333
+ }
334
+ let links: Record<string, string | number | undefined>
335
+ switch (provider) {
336
+ case 'github': {
337
+ const server = v('GITHUB_SERVER_URL') ?? 'https://github.com'
338
+ const repo = v('GITHUB_REPOSITORY')
339
+ const id = v('GITHUB_RUN_ID')
340
+ links = {
341
+ runUrl: repo && id ? `${server}/${repo}/actions/runs/${id}` : undefined,
342
+ // `refs/pull/<n>/merge` on a pull_request event.
343
+ change: /^refs\/pull\/(\d+)\//.exec(v('GITHUB_REF') ?? '')?.[1],
344
+ pipeline: v('GITHUB_WORKFLOW'),
345
+ job: v('GITHUB_JOB'),
346
+ attempt: n(v('GITHUB_RUN_ATTEMPT')),
347
+ }
348
+ break
349
+ }
350
+ case 'gitlab':
351
+ links = {
352
+ runUrl: v('CI_PIPELINE_URL'),
353
+ change: v('CI_MERGE_REQUEST_IID'),
354
+ pipeline: v('CI_PIPELINE_NAME'),
355
+ job: v('CI_JOB_NAME'),
356
+ }
357
+ break
358
+ case 'buildkite': {
359
+ const pr = v('BUILDKITE_PULL_REQUEST')
360
+ links = {
361
+ runUrl: v('BUILDKITE_BUILD_URL'),
362
+ // `false` when the build is not for one.
363
+ change: pr !== undefined && /^\d+$/.test(pr) ? pr : undefined,
364
+ pipeline: v('BUILDKITE_PIPELINE_SLUG'),
365
+ job: v('BUILDKITE_LABEL'),
366
+ // Counts re-runs from 0.
367
+ attempt: n(v('BUILDKITE_RETRY_COUNT'), 1),
368
+ }
369
+ break
370
+ }
371
+ case 'circleci':
372
+ links = {
373
+ runUrl: v('CIRCLE_BUILD_URL'),
374
+ // The PR's URL, ending in its number.
375
+ change: /\/(\d+)$/.exec(v('CIRCLE_PULL_REQUEST') ?? '')?.[1] ?? v('CIRCLE_PR_NUMBER'),
376
+ job: v('CIRCLE_JOB'),
377
+ }
378
+ break
379
+ default:
380
+ return {}
381
+ }
382
+ return Object.fromEntries(Object.entries(links).filter(([, x]) => x !== undefined)) as CiLinks
383
+ }
384
+
315
385
  /** Host name (null on failure) + platform + arch. */
316
386
  export function captureHostContext(): HostContext {
317
387
  let host: string | null = null
@@ -92,6 +92,7 @@ import { writeRunProfile, writeRunSummary } from './run-artifacts.js'
92
92
  import { createSaveLane } from './save-lane.js'
93
93
  import { formatRunSummary } from './summary.js'
94
94
  import { detectFlaky, type FlakyCandidate, type FlakyFinding } from './failure-mode.js'
95
+ import { createMissExplainer } from './miss-reason.js'
95
96
  import type { RunOptions, RunSummary } from './options.js'
96
97
 
97
98
  // Per run, never shared: a `vx watch` process runs many, and a shared map
@@ -747,6 +748,11 @@ async function runOnBus(
747
748
  dirty: gitContext.dirty,
748
749
  ci: ciContext.ci,
749
750
  ciProvider: ciContext.provider,
751
+ ...(ciContext.runUrl !== undefined ? { ciRunUrl: ciContext.runUrl } : {}),
752
+ ...(ciContext.change !== undefined ? { ciChange: ciContext.change } : {}),
753
+ ...(ciContext.pipeline !== undefined ? { ciPipeline: ciContext.pipeline } : {}),
754
+ ...(ciContext.job !== undefined ? { ciJob: ciContext.job } : {}),
755
+ ...(ciContext.attempt !== undefined ? { ciAttempt: ciContext.attempt } : {}),
750
756
  host: hostContext.host,
751
757
  os: hostContext.os,
752
758
  arch: hostContext.arch,
@@ -916,6 +922,10 @@ async function runOnBus(
916
922
  const dependedOn = new Set<string>()
917
923
  for (const n of nodes.values()) for (const d of n.deps) dependedOn.add(d)
918
924
 
925
+ // Why a task missed, for a sink: a read of the history per miss, so only
926
+ // when a sink exists.
927
+ const explainMiss =
928
+ telemetry === undefined ? undefined : createMissExplainer(prepared.localCache.dbHandle())
919
929
  const buildExecuteArgs = (node: TaskNode, upstream: TaskOutcome[], reuseProbe = true) => {
920
930
  const probe = reuseProbe ? shortCircuit.preProbed.get(node.id) : undefined
921
931
  const upfrontKey = shortCircuit.uncachedKeys.get(node.id)
@@ -947,6 +957,7 @@ async function runOnBus(
947
957
  persistentRegistry,
948
958
  liveChildren,
949
959
  ...(telemetry?.track !== undefined ? { track: telemetry.track } : {}),
960
+ ...(explainMiss !== undefined ? { explainMiss } : {}),
950
961
  gitFilesCache,
951
962
  hashCache,
952
963
  ...(probe !== undefined ? { preProbed: probe } : {}),
@@ -17,7 +17,13 @@
17
17
  // core calls INTO; those are the behavior capabilities, kept separate.
18
18
 
19
19
  import { sampleTrees } from '../exec/index.js'
20
- import { isGroupTask, type TaskNode, type TaskOutcome, type TaskStatus } from '../graph/index.js'
20
+ import {
21
+ isGroupTask,
22
+ type InputChanges,
23
+ type TaskNode,
24
+ type TaskOutcome,
25
+ type TaskStatus,
26
+ } from '../graph/index.js'
21
27
  import { maskedCommand, settleWithin, teardownTimeoutMs } from '../util/index.js'
22
28
  import type { RunEvent, RunEventSubscriber } from './events.js'
23
29
 
@@ -143,6 +149,16 @@ export interface RunContextRecord {
143
149
  dirty: boolean | null
144
150
  ci: boolean
145
151
  ciProvider: string | null
152
+ /** The CI provider's page for this run. */
153
+ ciRunUrl?: string
154
+ /** The pull or merge request number the run builds. */
155
+ ciChange?: string
156
+ /** The CI workflow or pipeline name. */
157
+ ciPipeline?: string
158
+ /** The CI job (or step) within it. */
159
+ ciJob?: string
160
+ /** 1 on a first run, 2 on its first re-run. */
161
+ ciAttempt?: number
146
162
  host: string | null
147
163
  os: string
148
164
  arch: string
@@ -207,6 +223,15 @@ export interface TaskTelemetry {
207
223
  storedPeakRssBytes?: number
208
224
  /** How long an `admit` policy held the task once it was ready. Additive. */
209
225
  admissionHeldMs?: number
226
+ /** How long the task waited ready for a worker, any admission hold included. Additive. */
227
+ queuedMs?: number
228
+ /** On a cacheable task that ran: how many files its key read. Additive. */
229
+ inputFiles?: number
230
+ /**
231
+ * On a cacheable task that ran, when the cache holds an earlier entry for
232
+ * it: what its key changed since, the first ten named. Additive.
233
+ */
234
+ inputChanges?: InputChanges
210
235
  /**
211
236
  * On a cache hit: whether outputs were written this run (`true`) or the
212
237
  * disk already matched the entry and nothing was restored (`false`, an
@@ -741,6 +766,9 @@ export function taskTelemetryOf(o: TaskOutcome): TaskTelemetry {
741
766
  if (o.storedCpuMs !== undefined) t.storedCpuMs = o.storedCpuMs
742
767
  if (o.storedPeakRssBytes !== undefined) t.storedPeakRssBytes = o.storedPeakRssBytes
743
768
  if (o.admissionHeldMs !== undefined) t.admissionHeldMs = o.admissionHeldMs
769
+ if (o.queuedMs !== undefined) t.queuedMs = o.queuedMs
770
+ if (o.inputFiles !== undefined) t.inputFiles = o.inputFiles
771
+ if (o.inputChanges !== undefined) t.inputChanges = o.inputChanges
744
772
  if (isCacheHit(o.status)) t.restored = o.restored === true
745
773
  if (o.wallclockStartNs !== undefined) t.wallclockStartNs = o.wallclockStartNs.toString()
746
774
  if (o.wallclockEndNs !== undefined) t.wallclockEndNs = o.wallclockEndNs.toString()
@@ -74,7 +74,9 @@ const decoder = new TextDecoder()
74
74
  * `^task` edges vanish, upstream hashes drop out of the cache key (stale
75
75
  * hits), and a second cache dir appears under the member. Claiming is decided
76
76
  * with the same globs `loadWorkspace` applies, so "the root that claims me"
77
- * and "the root that lists me as a project" cannot diverge.
77
+ * and "the root that lists me as a project" cannot diverge. An outer root
78
+ * that lists both the claimer and the claimed member outranks the claimer:
79
+ * from the claimer's own directory the walk reaches the outer root too.
78
80
  *
79
81
  * When no candidate claims `start` — a standalone package, or a subdirectory
80
82
  * of a single-project repo — the nearest candidate wins (the root itself IS
@@ -92,6 +94,9 @@ export async function findWorkspaceRoot(
92
94
  // The nearest manifest with workspace globs of its own, once passed: an
93
95
  // outer root claims the tree only by listing this directory itself.
94
96
  let inner: string | null = null
97
+ // The root that claims `start`, and the members it claimed it through.
98
+ let claimed: string | null = null
99
+ let members: string[] = []
95
100
  while (true) {
96
101
  let globs: string[] | null
97
102
  try {
@@ -105,12 +110,23 @@ export async function findWorkspaceRoot(
105
110
  }
106
111
  if (globs !== null) {
107
112
  nearest ??= dir
108
- if (claimsMember(dir, inner === null ? below : [inner], globs)) return dir
113
+ if (claimed !== null) {
114
+ // An outer root that lists both the claimer and the member owns
115
+ // them: `packages/**` holding `packages/inner` (with `workspaces:
116
+ // ['sub']`) and `packages/inner/sub` resolved `sub` to `inner` and
117
+ // `inner` to the outer root, two roots and two keys for one tree.
118
+ if (claimsMember(dir, [claimed], globs) && claimsMember(dir, members, globs)) claimed = dir
119
+ } else if (claimsMember(dir, inner === null ? below : [inner], globs)) {
120
+ claimed = dir
121
+ members = inner === null ? [...below] : [inner]
122
+ }
109
123
  // pnpm takes the nearest `pnpm-workspace.yaml` as the root, listed or
110
124
  // not. Walking past it, `apps/inner` resolved to the outer workspace
111
125
  // while `apps/inner/pkgs/x` resolved to the inner one: two roots and
112
126
  // two caches for one tree (item 990). Already read: `reads` holds it.
113
- if ((await readOnce(reads, path.join(dir, 'pnpm-workspace.yaml'))) !== null) return dir
127
+ if ((await readOnce(reads, path.join(dir, 'pnpm-workspace.yaml'))) !== null) {
128
+ return claimed ?? dir
129
+ }
114
130
  }
115
131
  const parent = path.dirname(dir)
116
132
  if (parent === dir) break
@@ -126,6 +142,7 @@ export async function findWorkspaceRoot(
126
142
  if (inner === null && globs !== null && globs.length > 0) inner = dir
127
143
  dir = parent
128
144
  }
145
+ if (claimed !== null) return claimed
129
146
  if (nearest !== null) return nearest
130
147
  throw new UserError(
131
148
  `Could not find a workspace root in any parent of ${start} ` +