Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save wycats/058681631ed0bbced80d48180b8891b1 to your computer and use it in GitHub Desktop.

Select an option

Save wycats/058681631ed0bbced80d48180b8891b1 to your computer and use it in GitHub Desktop.
ERR_PNPM_LOCKFILE_CONFIG_MISMATCH Investigation (pm-core bug fix)

ERR_PNPM_LOCKFILE_CONFIG_MISMATCH Investigation

Reference: pm-core-explainer.md for the conceptual model (signal hierarchy, coherence, the three-phase pipeline).

The Goal

We want v0 to do less bespoke shit. This means:

  1. Align with Vercel platform behavior: if a project builds on platform, it should build on v0.
  2. Align with "normal dev machines": if a project builds on a typical developer's local setup, it should build on v0.

Defining "normal dev machine" precisely is a project for later — but the spirit is clear: v0 shouldn't require special incantations that wouldn't work elsewhere.

However, regressions from main create bug reports. We can't hand-wave them away. Even if main's behavior was accidental or sloppy, users depend on it.

The framing:

  1. Main worked (even if we don't know exactly why)
  2. pm-core broke it (we have bug reports)
  3. We need to understand why main worked before deciding if the breakage is acceptable

Valid reasons to accept a breaking change:

  • "We broke it to match platform behavior" (verify this is true)
  • "We broke it because the old behavior wouldn't work locally" (verify this too)

Invalid reasons:

  • "Main was sloppy so we don't care" (users still depend on it)
  • "Our code analysis says it should work" (production says otherwise)

The resolution should refine pm-core's abstraction, not add special cases. But we need to understand the actual behavior first, not just theorize.

What Changed

The pm-core branch replaced v0's entire package manager system. This isn't a tweak — it's a rewrite of detection, version resolution, and command generation.

Main Branch (Old System)

detectPackageManagerFromFiles(files)
  → scan for lockfile presence → pick PM
  → parse packageManager field → extract pnpmMajorVersion (e.g., 9)
  → return { packageManager: 'pnpm', pnpmMajorVersion: 9, hasLockfile: true }

installDependencies(pm, mode)
  → strategy.getInstallArgs(mode, { pnpmMajorVersion })
  → sandbox.runCommand({ cmd: 'pnpm', args: ['install', '--frozen-lockfile', ...] })

Commands run via bare pnpm on PATH (whatever version the sandbox has: 9.0.6). Version selection is implicit — the sandbox's installed pnpm runs.

pm-core Branch (New System)

detectPackageManagerFromFiles(files)
  → analyzeProject(files) → candidates with evidence quality
  → resolveContract(candidates, lockfile) → version constraint
  → resolveVersion(pm, constraint, AVAILABLE_VERSIONS) → concrete version
  → return { packageManager: 'pnpm', resolvedVersion: '9.15.0', contract, ... }

getInstallCommand(resolved, mode)
  → pmCoreGetSetupCommand(resolved, intent) → args
  → corepackCommand(pm, version, args)
  → return { cmd: 'corepack', args: ['pnpm@9.15.0', 'install', '--frozen-lockfile'] }

Commands run via corepack with an explicit version. Version selection is explicit — pm-core resolves a version from available candidates based on lockfile evidence and constraint narrowing.

The Architectural Difference

Aspect Main pm-core
Detection Lockfile presence + packageManager field Signal hierarchy with evidence quality
Version Implicit (sandbox's pnpm) Explicit (resolved from constraints)
Invocation pnpm install ... corepack pnpm@X.Y.Z install ...
Retry Same pnpm version Same resolved version

The Questions

Primary Question

What is the actual behavioral difference between main and pm-core for this scenario, and is that difference a regression?

We need to answer empirically:

  1. What command does main run? (version, flags, retry behavior)
  2. What command does pm-core run? (version, flags, retry behavior)
  3. What command does platform run? (the ground truth)
  4. Where do they diverge?

Secondary Question

If pm-core's behavior differs from main, is that a bug or an improvement?

  • If pm-core matches platform but main doesn't → main was wrong, pm-core is correct
  • If pm-core diverges from platform → pm-core has a bug to fix
  • If both diverge from platform → both need fixing, but pm-core should fix it properly

Framing the Answer

The answer should express the root cause through pm-core's signal hierarchy and coherence model. If the model is incomplete (e.g., doesn't account for some signal), we extend the model. If the resolution logic is wrong, we fix it. We don't add special cases.

The Bug

After merging the pm-core branch, some v0 chat sessions fail with:

ERR_PNPM_LOCKFILE_CONFIG_MISMATCH

Cannot proceed with the frozen installation. The current "patchedDependencies"
configuration doesn't match the lockfile.

The Scenario

The failing sessions are from a user who imported the full v0 monorepo as their project. This was confirmed by Max via Slack — the "44 workspace projects" in the production logs are the v0 monorepo's own workspaces, and the patchedDependencies are the v0 monorepo's ~20+ patch entries (radix-ui, linkedom, react-three, monaco-vscode, etc.).

The golden image / base template (vercel/v0-next-base) is a flat Next.js + shadcn app with no pnpm-workspace.yaml, no patchedDependencies, and no monorepo structure. It is not the source of the problem.

Why This Scenario Matters

v0 importing v0 is a valid test case, not an edge case to dismiss. The v0 monorepo builds successfully on Vercel platform. pm-core's goal is: if it builds on platform, it should build on v0. This means there exists a working command sequence that platform runs. pm-core should find it.

If pm-core fails where platform succeeds, that's a gap in pm-core's modeling — either we're not detecting the right signals, or we're not translating them into the right commands.

Does the Golden Snapshot Require a Retry?

No. The golden snapshot (vercel/v0-next-base) is a flat Next.js + shadcn app. It has no patchedDependencies, no pnpm-workspace.yaml, and no complex config. A frozen install against the golden snapshot's own lockfile should succeed without retry.

The retry is only triggered when a user imports a project whose lockfile is stale relative to its package.json config — for example, importing the full v0 monorepo, which has patchedDependencies entries that may not match the lockfile after the sandbox's merge/copy process.

The Install Flow (Verified from Code)

Both branches have the same retry structure in PackageManagerModule:

// chat/app/chat/api/vm/modules/package-manager.ts (both branches)
async installDependencies(pm, mode) {
  const result = await this._installDependenciesInternal(pm, mode)
  if (result.exitCode !== 0 && mode === 'frozen') {
    console.log('Frozen install failed, falling back to fresh install...')
    await appendVMLog(vmKey, 'Lockfile mismatch detected, regenerating...\n')
    return this._installDependenciesInternal(pm, 'fresh')
  }
  return result
}

The difference is in how the command is constructed:

Main Branch

// _installDependenciesInternal calls:
sandbox.runCommand({
  cmd: packageManager, // 'pnpm'
  args: strategy.getInstallArgs(mode, { pnpmMajorVersion }),
  // ...
})

This runs bare pnpm — whatever version is on PATH (9.0.6 in the sandbox).

pm-core Branch

// _installDependenciesInternal calls:
const command = getInstallCommand(detection.resolvedVersion, mode, options)
sandbox.runCommand({
  cmd: command.cmd, // 'corepack'
  args: command.args, // ['pnpm@9.15.0', 'install', '--frozen-lockfile', ...]
  // ...
})

This runs corepack with an explicit version resolved from lockfile evidence.

The Version Resolution Path (pm-core)

lockfileVersion: '9.0'
  → mapLockfileIdToContract() → { constraint: '>=9.0.0 <11.0.0', ambiguous: true }
  → resolveContract() checks for narrowing candidates (quality >= medium)
  → No trusted narrowing candidates found
  → narrowToLowerMajor() → '>=9.0.0 <10.0.0'
  → resolveVersion() walks AVAILABLE_VERSIONS backwards
  → 10.0.0 doesn't satisfy '>=9.0.0 <10.0.0'
  → 9.15.0 satisfies → picks 9.15.0

Verified by test: pm-core correctly resolves to 9.15.0 for the v0 monorepo. See packages/pm-core/src/v0-repro.test.ts.

Production Logs (Verbatim)

⚠️ PRESERVE THESE LOGS — they are the primary evidence. Do not summarize or paraphrase. If context is lost, ask for the original logs again.

Key Observation

The pm-core branch logs show pnpm: "10.0.0" in the engine warning, but our code analysis shows pm-core should resolve to 9.15.0. This discrepancy is the central puzzle — either:

  1. The deployed code differs from what we analyzed
  2. There's a code path that bypasses narrowToLowerMajor()
  3. We're misunderstanding something about the resolution flow

"With" run (pm-core branch)

https://v0.app/chat/hello-world-RcPinlKvfWP

Scope: all 44 workspace projects
10:06:21.662Z
[SERVER]
 ERR_PNPM_LOCKFILE_CONFIG_MISMATCH  Cannot proceed with the frozen installation.
 The current "patchedDependencies" configuration doesn't match the value found
 in the lockfile

Update your lockfile using "pnpm install --no-frozen-lockfile"
10:06:21.670Z
[SERVER]
Lockfile mismatch detected, regenerating...
10:06:21.674Z
[SERVER]
Installing dependencies...
10:06:22.234Z
[SERVER]
 WARN  Issue while reading "/vercel/share/v0-next-shadcn/chat/.npmrc".
       Failed to replace env in config: ${VERCEL_PRIVATE_REGISTRY_TOKEN}
 WARN  Issue while reading "/vercel/share/v0-next-shadcn/.npmrc".
       Failed to replace env in config: ${VERCEL_PRIVATE_REGISTRY_TOKEN}
10:06:22.295Z
[SERVER]
..
 WARN  Unsupported engine: wanted: {"node":"22.x"}
       (current: {"node":"v24.13.0","pnpm":"10.0.0"})

"Without" run (main branch)

https://v0.app/chat/vercel-v0-s2dwoBASKy1

Lockfile mismatch detected, regenerating...
10:08:34.044Z
[SERVER]
Installing dependencies...
10:08:34.547Z
[SERVER]
 WARN  Issue while reading "/vercel/share/v0-next-shadcn/chat/.npmrc".
       Failed to replace env in config: ${VERCEL_PRIVATE_REGISTRY_TOKEN}
 WARN  Issue while reading "/vercel/share/v0-next-shadcn/.npmrc".
       Failed to replace env in config: ${VERCEL_PRIVATE_REGISTRY_TOKEN}
10:08:34.693Z
[SERVER]
Installing dependencies with pnpm@9.0.6...
10:08:35.002Z
[SERVER]
 WARN  Issue while reading "/vercel/share/v0-next-shadcn/chat/.npmrc".
       Failed to replace env in config: ${VERCEL_PRIVATE_REGISTRY_TOKEN}
 WARN  Issue while reading "/vercel/share/v0-next-shadcn/.npmrc".
       Failed to replace env in config: ${VERCEL_PRIVATE_REGISTRY_TOKEN}
10:08:35.449Z
[SERVER]
 WARN  Issue while reading "/vercel/share/v0-next-shadcn/chat/.npmrc".
       Failed to replace env in config: ${VERCEL_PRIVATE_REGISTRY_TOKEN}
 WARN  Issue while reading "/vercel/share/v0-next-shadcn/.npmrc".
       Failed to replace env in config: ${VERCEL_PRIVATE_REGISTRY_TOKEN}

Log Analysis

"With" run observations:

  1. No "Installing dependencies with pnpm@10.0.0..." echo — the pm-core branch changed the install command from bash -c "echo ... && pnpm install ..." to corepack pnpm@10.0.0 install ..., so the version announcement is gone. The engine warning reveals pnpm: "10.0.0".
  2. Scope: all 44 workspace projects appears before the error — pnpm discovers the imported monorepo's pnpm-workspace.yaml.
  3. The frozen install fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.
  4. Retry fires ("Lockfile mismatch detected, regenerating..."), then "Installing dependencies..." (the fresh install).
  5. .npmrc warnings reference PREVIEW_CWD/chat/.npmrc and PREVIEW_CWD/.npmrc — these are the v0 monorepo's own .npmrc files.
  6. Engine warning: sandbox runs Node 24.13.0 but monorepo wants node: "22.x".

"Without" run observations:

  1. The frozen install's ERR_PNPM_LOCKFILE_CONFIG_MISMATCH is not visible in the logs — either truncated or the old code's piped-through-bash output doesn't surface it the same way.
  2. "Lockfile mismatch detected, regenerating..." appears first — the retry.
  3. "Installing dependencies..." — the appendVMLog message for the fresh install.
  4. "Installing dependencies with pnpm@9.0.6..." — the bash echo from the old install command (echo "... with pnpm@$(pnpm --version)..." && pnpm install).
  5. .npmrc warnings appear THREE times — once during the retry's fresh install, and twice more during what appears to be a second install pass.

The Critical Open Question

The logs were presented as "imported with your changes" (broken) vs "without" (working). We've been assuming both recover via retry, but we don't actually know that from the logs alone. The "without" run shows the retry firing and then a second install pass — but we don't see the final exit code or whether the session loaded successfully.

Hypothesis Tree

main succeeds, pm-core fails
│
├─ H1: pm-core resolves to wrong version
│   ├─ Evidence: Production logs show pnpm: "10.0.0"
│   ├─ Code analysis: narrowToLowerMajor should produce 9.15.0
│   ├─ Unit test: Confirms 9.15.0 resolution
│   └─ Status: DISCREPANCY — code says 9.15.0, production shows 10.0.0
│       └─ Next: Verify deployed code matches analyzed code
│
├─ H2: Retry behavior differs
│   ├─ Evidence: Both logs show "Lockfile mismatch detected, regenerating..."
│   ├─ Code analysis: Same retry structure in PackageManagerModule
│   └─ Status: VERIFIED SAME — retry fires on both branches
│
├─ H3: Fresh install fails on pm-core but succeeds on main
│   ├─ Evidence: Logs cut off before showing final outcome
│   ├─ Local experiment: Both 9.0.6 and 10.0.0 succeed on fresh retry
│   └─ Status: UNCLEAR — need to see full production logs or final exit code
│
└─ H4: Environment difference (corepack, PATH, etc.)
    ├─ Evidence: pm-core uses corepack, main uses bare pnpm
    ├─ Tested: NOT YET
    └─ Status: OPEN

The Central Discrepancy

Source pnpm Version
Production logs (pm-core) 10.0.0
Code analysis (narrowToLowerMajor) 9.15.0
Unit test (v0-repro.test.ts) 9.15.0
Main branch (sandbox PATH) 9.0.6

This is the puzzle. Our code analysis and unit tests show pm-core should resolve to 9.15.0, but production ran 10.0.0. Either:

  1. Deployed code differs: The production deployment doesn't have narrowToLowerMajor (maybe an older version?)
  2. Code path we missed: There's a branch in the resolution logic we haven't traced
  3. Test doesn't match production: Our unit test doesn't accurately simulate the production flow

Empirical Work Needed

  1. Verify deployed code: Check if the production pm-core branch has narrowToLowerMajor in resolveContract()
  2. Trace full resolution path: Add logging or write a test that exactly mirrors the production call sequence
  3. Get full production logs: The current logs cut off — we need to see the final exit code and whether the session loaded

What We've Verified (and What We Haven't)

✅ Verified: Code Analysis

The current code in this worktree resolves to 9.15.0:

lockfileVersion: '9.0'
  → mapLockfileIdToContract() → { constraint: '>=9.0.0 <11.0.0', ambiguous: true }
  → resolveContract() → narrowToLowerMajor() → '>=9.0.0 <10.0.0'
  → resolveVersion() → 9.15.0

Verified by: packages/pm-core/src/v0-repro.test.ts

✅ Verified: Retry Logic

Both branches have identical retry logic in PackageManagerModule:

  • If frozen install fails → log "Lockfile mismatch detected" → retry fresh

Verified by: Code reading of both branches

✅ Verified: Local Experiments

Version Frozen Fresh
pnpm 9.0.6 ❌ ERR_PNPM_LOCKFILE_CONFIG_MISMATCH ✅ Success
pnpm 9.15.0 ✅ Success ✅ Success
pnpm 10.0.0 ❌ ERR_PNPM_LOCKFILE_CONFIG_MISMATCH ✅ Success

Verified by: Running commands in /tmp/v0-repro

❌ NOT Verified: Production Code

We have not verified that the deployed pm-core branch matches the code we analyzed. The production logs show pnpm: "10.0.0", but our analysis says it should resolve to 9.15.0.

❌ NOT Verified: Final Outcome

The production logs cut off. We don't know if the fresh install succeeded or failed, or what the final session state was.

Command Generation (pm-core)

For frozen install with resolved version 9.15.0:

getSetupCommand({ pm: 'pnpm', version: '9.15.0' }, { coherence: 'assert', install: true })
  → { args: ['install', '--frozen-lockfile'], pm: 'pnpm' }
  → corepackCommand() wraps as: { cmd: 'corepack', args: ['pnpm@9.15.0', 'install', '--frozen-lockfile'] }

Status: ✅ Verified by unit test. Commands are correct.

Retry Logic (Both Branches)

Both branches have identical retry logic in PackageManagerModule.installDependencies():

  • If frozen install fails (exitCode !== 0), log "Lockfile mismatch detected"
  • Retry with fresh mode

Status: ✅ Verified by code reading. Retry logic is identical.

Production Behavior

Production logs show:

  • pm-core branch: Frozen fails → retry fires → fresh install starts → ???
  • main branch: Frozen fails → retry fires → fresh install starts → succeeds

Status: ⚠️ We see retry fires on both, but don't see pm-core's final outcome.

Open Question

If pm-core resolves correctly (9.15.0), generates correct commands, and retry fires, why does production fail? Possibilities:

  1. Production code differs from what we analyzed (deployment timing?)
  2. Environment difference (corepack version, PATH, sandbox state)
  3. The failure is in the fresh install, not the frozen install
  4. Something in the logs we're misreading

Local Reproduction

Setup

Two scripts in docs/investigations/ recreate the reproduction environment:

# 1. Set up the environment (clone monorepo, install corepack)
./docs/investigations/repro-setup.sh

# 2. Trace pm-core's detection + resolution against the clone
cd /tmp/v0-repro && node <repo>/docs/investigations/repro-trace.ts

repro-setup.sh clones the current monorepo to /tmp/v0-repro and installs corepack to /tmp/corepack-home. It prints the experiment commands to run.

repro-trace.ts simulates pm-core's detectPackageManagerFromFiles() path: reads project files, extracts signals, traces the version-map lookup and resolveVersion() walk, and checks patchedDependencies alignment.

Prerequisites: Node 24+ (native TS), proto (for running specific pnpm versions).

Experiments

cd /tmp/v0-repro
export COREPACK="/tmp/corepack-home/node_modules/.bin/corepack"

Experiment A: pm-core path (corepack pnpm@10.0.0, frozen)

COREPACK_ENABLE_STRICT=0 $COREPACK pnpm@10.0.0 install --frozen-lockfile --prefer-offline

Experiment B: pm-core path (corepack pnpm@10.0.0, fresh retry)

COREPACK_ENABLE_STRICT=0 $COREPACK pnpm@10.0.0 install --no-frozen-lockfile --prefer-offline

Experiment C: main path (bare pnpm 9.0.6, frozen)

proto run pnpm 9.0.6 -- install --frozen-lockfile --prefer-offline

Experiment D: main path (bare pnpm 9.0.6, fresh retry)

proto run pnpm 9.0.6 -- install --no-frozen-lockfile --prefer-offline

Experiment E: correct resolution (corepack pnpm@9.15.0, frozen)

COREPACK_ENABLE_STRICT=0 $COREPACK pnpm@9.15.0 install --frozen-lockfile --prefer-offline

Results

Experiment Version Mode Result
A pnpm 10.0.0 frozen ERR_PNPM_LOCKFILE_CONFIG_MISMATCH
B pnpm 10.0.0 fresh ✅ Success (2m 33s)
C pnpm 9.0.6 frozen ERR_PNPM_LOCKFILE_CONFIG_MISMATCH
D pnpm 9.0.6 fresh ✅ Success (4m 5s)
E pnpm 9.15.0 frozen Success (1m 18s)

Observations

  1. pnpm 9.15.0 frozen succeeds where 9.0.6 and 10.0.0 fail
  2. Both 9.0.6 and 10.0.0 succeed on fresh retry
  3. The patchedDependencies mismatch error is version-specific

What This Tells Us

  • H1 is partially refuted: The retry path succeeds for both versions. If pm-core runs the retry, it should work. This shifts focus to H2/H3/H4.

  • Correlation ≠ Causation: We observed 9.15.0 succeeds frozen, but we don't know why. We assumed "patchedDependencies handling changed" without verifying. This may be true but isn't proven.

  • The real gap: We haven't verified what commands pm-core actually generates and runs. We've been testing assumed commands, not actual commands.

Root Cause Analysis

The Bug: Empty Lockfile Buffer

Location: chat/app/chat/api/vm/modules/vm-lifecycle-manager.ts, line ~1689

// Check for existing lockfiles
if (lockfileOutput?.trim()) {
  const foundFiles = lockfileOutput.trim().split('\n')
  for (const lockfile of lockfiles) {
    if (foundFiles.some((f) => f.endsWith(lockfile))) {
      filesMap.set(lockfile, Buffer.from('')) // ← BUG: empty buffer!
      hasLockfile = true
      break
    }
  }
}

The detectPackageManagerAndLockfileFromPreview() function checks whether a lockfile exists but doesn't read its content. It passes an empty buffer to pm-core, which means pm-core can't parse lockfileVersion: '9.0' from the lockfile.

Why This Causes the Wrong Version

Without lockfile content, pm-core's detection flow:

analyzeProject(filesMap)
  → lockfile exists but content is empty
  → can't parse lockfileVersion
  → no lockfile-version signal
  → falls back to defaultContract('pnpm') = { constraint: '>=9.0.0' }
  → resolveVersion('pnpm', '>=9.0.0', AVAILABLE_VERSIONS)
  → walks backwards: 10.0.0 satisfies >=9.0.0 → picks 10.0.0

With lockfile content (what should happen):

analyzeProject(filesMap)
  → parses lockfileVersion: '9.0'
  → mapLockfileIdToContract() → { constraint: '>=9.0.0 <11.0.0', ambiguous: true }
  → resolveContract() sees ambiguous=true, no narrowing signals
  → narrowToLowerMajor() → '>=9.0.0 <10.0.0'
  → resolveVersion('pnpm', '>=9.0.0 <10.0.0', AVAILABLE_VERSIONS)
  → walks backwards: 10.0.0 doesn't satisfy, 9.15.0 satisfies → picks 9.15.0

What Exactly Is ERR_PNPM_LOCKFILE_CONFIG_MISMATCH?

The error message says:

The current "patchedDependencies" configuration doesn't match the value found in the lockfile

The mismatch is between two different representations of patchedDependencies:

In package.json (simple path strings):

"pnpm": {
  "patchedDependencies": {
    "@radix-ui/react-slot@1.0.2": "patches/@radix-ui__react-slot@1.0.2.patch"
  }
}

In pnpm-lock.yaml (lockfile v9 format with hash + path):

patchedDependencies:
  '@radix-ui/react-slot@1.0.2':
    hash: abc123def456
    path: patches/@radix-ui__react-slot@1.0.2.patch

The lockfile v9 format (introduced in pnpm 9.0.0) stores a hash of each patch file alongside the path. When pnpm runs --frozen-lockfile, it compares the current patchedDependencies config against what's recorded in the lockfile.

Why different versions fail:

Version Why it fails frozen
pnpm 9.0.6 Early 9.x version may not handle the hash comparison correctly for lockfiles created by 9.14+
pnpm 10.0.0 Bug in early 10.x: ignored patchedDependencies from package.json (fixed in 10.6.1)
pnpm 9.15.0 ✅ Correctly handles the lockfile v9 format with hashes

Note: Our AVAILABLE_VERSIONS includes 10.0.0, which has this bug. We may want to update to 10.6.1 or later.

How This Explains the Production Logs

Observation Explanation
Production shows pnpm: "10.0.0" Empty lockfile buffer → unbounded constraint → newest version
Our unit test shows 9.15.0 Unit test passes real lockfile content → ambiguous → narrowed
Frozen install fails with ERR_PNPM_LOCKFILE_CONFIG_MISMATCH pnpm 10.0.0 has a bug ignoring patchedDependencies from package.json
Fresh retry succeeds pnpm 10.0.0 regenerates the lockfile, writing its own patchedDependencies
Main branch succeeds (after retry) Uses bare pnpm 9.0.6 on PATH, which also fails frozen but succeeds fresh

Why pm-core Would Have Been Better (If Working Correctly)

Our local experiments showed:

Version Frozen Fresh
pnpm 9.0.6 (main) ❌ Fails ✅ Success (4m 5s)
pnpm 9.15.0 (pm-core intended) Success (1m 18s) ✅ Success
pnpm 10.0.0 (pm-core actual) ❌ Fails ✅ Success (2m 33s)

If pm-core had resolved correctly to 9.15.0:

  • Frozen install would succeed on first try
  • No retry needed
  • Faster than both main (which retries) and current pm-core (which retries)

Why 9.15.0 succeeds where 9.0.6 fails:

The v0 monorepo's lockfile has lockfileVersion: '9.0' with the hash-based patchedDependencies format. This format was introduced in pnpm 9.0.0, but the comparison logic for patchedDependencies was refined in later 9.x versions.

pnpm 9.0.6 (released April 2024) is an early 9.x release. pnpm 9.15.0 (released later) has bug fixes for the patchedDependencies comparison that make it correctly handle lockfiles created by other 9.x versions.

This is not about "reading the format" — both versions can read lockfile v9. It's about the frozen-lockfile validation logic that compares the current config against the lockfile's recorded state.

Open Question: Is defaultContract('pnpm') = '>=9.0.0' Wrong?

When pm-core can't parse the lockfile (empty buffer), it falls back to defaultContract('pnpm') which returns >=9.0.0 (unbounded upper).

This seems incongruous with the narrowToLowerMajor policy elsewhere:

  • When we have an ambiguous lockfile (>=9.0.0 <11.0.0), we narrow to >=9.0.0 <10.0.0
  • But when we have no lockfile signal at all, we use >=9.0.0 (unbounded)

The unbounded default picks 10.0.0 (newest), while the narrowed ambiguous picks 9.15.0. This inconsistency suggests defaultContract should also prefer 9.x:

// Current (problematic):
pnpm: '>=9.0.0' // → resolves to 10.0.0

// Consistent with narrowToLowerMajor policy:
pnpm: '>=9.0.0 <10.0.0' // → resolves to 9.15.0

However, fixing the empty buffer bug is the primary fix. The defaultContract question is secondary — it only matters when there's truly no lockfile.

The Irony

pm-core's design is correct:

  • Detect lockfile version → derive constraint → pick compatible version
  • The narrowToLowerMajor policy is sound (prefer 9.x over 10.x for ambiguous lockfiles)
  • 9.15.0 is the right answer for this scenario

But the integration is broken:

  • vm-lifecycle-manager doesn't pass lockfile content to pm-core
  • pm-core falls back to unbounded constraint
  • Resolves to 10.0.0 instead of 9.15.0

Origin of the Bug

The empty buffer pattern predates pm-core. It was introduced in commit 1707467392 (Jan 28, 2026) by Max Leiter in "VMs: better npm support, refactor package manager, introduce strategies".

At that time, detection only needed to know whether a lockfile existed (to choose frozen vs fresh mode). The old system didn't parse lockfile content for version detection — it used the packageManager field or defaulted to the sandbox's pnpm.

When pm-core was integrated, this code path wasn't updated to read lockfile content. The property tests pass real content directly to detectPackageManagerFromFiles(), so they didn't catch the gap.

Testing Gap Analysis

Why Property Tests Didn't Catch It

The property tests in package-manager.pipeline.property.test.ts test the adapter's resolution logic by passing real lockfile content:

function lockfileFor(pm: BasePm): [string, string] {
  case 'pnpm':
    return ['pnpm-lock.yaml', "lockfileVersion: '7.0'\n"]  // ← real content
}

They test detectPackageManagerFromFiles() directly, bypassing the vm-lifecycle-manager layer that reads files from the sandbox.

Why vm-lifecycle-manager Tests Didn't Catch It

The tests in vm-lifecycle-manager.test.ts mock the detection function:

vi.spyOn(
  manager as any,
  'detectPackageManagerAndLockfileFromPreview',
).mockResolvedValue({
  pm,
  hasLockfile,
  resolvedVersion, // ← hardcoded, never exercises real detection
})

The Gap

Test Suite What it tests What it misses
pm-core unit tests Signal parsing, constraint mapping How the app reads files
Adapter property tests detectPackageManagerFromFiles() with real content How vm-lifecycle-manager builds filesMap
vm-lifecycle-manager tests Install mode selection, retry logic Mocks away detection entirely

Missing: Integration test that verifies detectPackageManagerAndLockfileFromPreview() passes real lockfile content to pm-core and gets the expected resolved version.

The Fix

Immediate Fix

Update detectPackageManagerAndLockfileFromPreview() to read lockfile content:

// Instead of just checking existence with `ls`:
const result = await runCommand(this.sandbox, {
  cmd: 'sh',
  args: [
    '-c',
    `cat ${PREVIEW_CWD}/package.json 2>/dev/null; echo "---WORKSPACE---"; ` +
    `cat ${PREVIEW_CWD}/pnpm-workspace.yaml 2>/dev/null; echo "---LOCKFILES---"; ` +
    `cat ${PREVIEW_CWD}/pnpm-lock.yaml 2>/dev/null; echo "---LOCKFILE:pnpm-lock.yaml---"; ` +
    // ... similar for other lockfiles
  ],
})

// Then parse and pass real content:
if (lockfileContent?.trim()) {
  filesMap.set('pnpm-lock.yaml', Buffer.from(lockfileContent.trim()))
}

Testing Fix

Add an integration test that:

  1. Sets up a mock sandbox with realistic file content
  2. Calls detectPackageManagerAndLockfileFromPreview()
  3. Verifies the resolved version matches pm-core's expected output

Future Work: Integration Test Matrix

The Problem

We have good unit tests for pm-core and good property tests for the adapter, but no integration tests that verify the full flow from "files in sandbox" to "resolved version passed to install command".

Proposed Solution

Create an integration test matrix that:

  1. Generates realistic repo scenarios programmatically:

    • Various lockfile versions (pnpm 7.0, 9.0, npm v3, yarn berry, etc.)
    • With/without packageManager field
    • With/without engines constraints
    • Monorepo vs single-package
    • With/without pnpm-workspace.yaml
  2. Exercises the full detection path:

    • Mock sandbox that returns file content (not just existence)
    • Call detectPackageManagerAndLockfileFromPreview()
    • Verify resolved version against expected value
  3. Validates against ground truth:

    • What version would platform use?
    • What version would a "normal dev machine" use?

Implementation Sketch

interface RepoScenario {
  name: string
  files: Record<string, string> // filename → content
  expectedPm: PackageManager
  expectedVersion: string
  platformBehavior?: string // what platform would do
}

const scenarios: RepoScenario[] = [
  {
    name: 'pnpm 9.x lockfile, no narrowing signals',
    files: {
      'package.json': '{}',
      'pnpm-lock.yaml': "lockfileVersion: '9.0'\n",
    },
    expectedPm: 'pnpm',
    expectedVersion: '9.15.0', // narrowToLowerMajor picks 9.x
  },
  {
    name: 'pnpm 9.x lockfile with engines.pnpm >= 10',
    files: {
      'package.json': '{"engines":{"pnpm":">=10"}}',
      'pnpm-lock.yaml': "lockfileVersion: '9.0'\n",
    },
    expectedPm: 'pnpm',
    expectedVersion: '10.0.0', // engines narrows to 10.x
  },
  // ... more scenarios
]

describe('integration: sandbox → detection → resolution', () => {
  for (const scenario of scenarios) {
    it(scenario.name, async () => {
      const sandbox = createMockSandboxWithFiles(scenario.files)
      const manager = new VMLifecycleManager({ sandbox, vmKey: 'test' })

      const { resolvedVersion } =
        await manager.detectPackageManagerAndLockfileFromPreview()

      expect(resolvedVersion.version).toBe(scenario.expectedVersion)
    })
  }
})

This would have caught the empty-buffer bug immediately.

Empirical Testing Results

Test Setup

Created a minimal test case at /tmp/pnpm-test to empirically validate the patchedDependencies compatibility hypothesis. All tests run on 2026-02-17.

The Hash Format Signal

pnpm 9.x creates short hashes (26 chars, base32-like):

patchedDependencies:
  is-odd@1.0.0:
    hash: ywj2pj2yppbllpyludxooiduvi
    path: patches/is-odd@1.0.0.patch

pnpm 10.x creates long hashes (64 chars, SHA256 hex):

patchedDependencies:
  is-odd@1.0.0:
    hash: 5b663d9ac9d517a8a958e72d21bb55f75fb3cef2abd1dc474099b97dd1e67b4a
    path: patches/is-odd@1.0.0.patch

v0's lockfile uses the 9.x format (confirmed):

patchedDependencies:
  '@alexandernanberg/react-pdf-renderer@4.0.0-canary-2':
    hash: jeapvpqkhq3urj322bknzhtwim # ← 26 chars, 9.x format

Critical Finding: lockfileVersion Does NOT Distinguish 9.x from 10.x

Both pnpm 9.x and 10.x write lockfileVersion: '9.0'. The lockfile version field is not a signal for distinguishing which major version created the lockfile. The hash format in patchedDependencies is the distinguishing signal.

Cross-Version Compatibility Matrix

Lockfile Created By Frozen Install With Result
pnpm 9.15.0 (no patches) pnpm 9.0.6 ✅ Success
pnpm 9.15.0 (no patches) pnpm 10.0.0 ✅ Success
pnpm 9.15.0 (with patches) pnpm 9.0.6 ✅ Success
pnpm 9.15.0 (with patches) pnpm 9.15.0 ✅ Success
pnpm 9.15.0 (with patches) pnpm 10.0.0 ❌ ERR_PNPM_LOCKFILE_CONFIG_MISMATCH
pnpm 9.15.0 (with patches) pnpm 10.6.1 ❌ ERR_PNPM_LOCKFILE_CONFIG_MISMATCH
pnpm 10.0.0 (with patches) pnpm 10.0.0 ✅ Success
pnpm 10.0.0 (with patches) pnpm 9.15.0 ❌ ERR_PNPM_LOCKFILE_CONFIG_MISMATCH

Key insight: Without patchedDependencies, 9.x and 10.x lockfiles are interchangeable. The incompatibility is specifically the hash format.

What Retry Does (Empirically Verified)

Scenario: 9.x lockfile with patchedDependencies, run with pnpm 10.0.0

Step 1: Frozen install fails

$ pnpm@10.0.0 install --frozen-lockfile
ERR_PNPM_LOCKFILE_CONFIG_MISMATCH
Cannot proceed with the frozen installation.
The current "patchedDependencies" configuration doesn't match the value found in the lockfile

Step 2: Fresh install (retry) succeeds

$ pnpm@10.0.0 install --no-frozen-lockfile
Already up to date
Done in 186ms

What changed: The lockfile was rewritten with 10.x hash format:

patchedDependencies:
  is-odd@1.0.0:
-    hash: ywj2pj2yppbllpyludxooiduvi
+    hash: 5b663d9ac9d517a8a958e72d21bb55f75fb3cef2abd1dc474099b97dd1e67b4a
    path: patches/is-odd@1.0.0.patch

The retry rewrites the lockfile to match the running pnpm version's hash format. This is why retry "works" — it doesn't fix the incompatibility, it converts the lockfile to the new format.

Reverse Direction Also Fails

Scenario: 10.x lockfile with patchedDependencies, run with pnpm 9.15.0

$ pnpm@9.15.0 install --frozen-lockfile
ERR_PNPM_LOCKFILE_CONFIG_MISMATCH

Fresh retry succeeds and rewrites the hash back to 26-char format.

Summary: Checksum Format Compatibility Rules

Field 9.x Format 10.x Format
patchedDependencies hash 26-char base32 64-char SHA256 hex
packageExtensionsChecksum 32-char MD5 sha256-... base64
pnpmfileChecksum 32-char MD5 64-char SHA256 hex
Side effects cache keys MD5 SHA256

The checksum format is a hard boundary. Any of these fields, if present, locks the lockfile to its major version. You cannot use a lockfile with any of these checksummed fields across the 9.x/10.x boundary without rewriting it.

Why pnpm Didn't Bump lockfileVersion to 10

This is a deliberate choice, not an oversight. When a user asked exactly this question, zkochan (pnpm maintainer) explained:

The version in the lockfile is only bumped when there are some breaking changes to the format. It isn't necessarily bumped in every major release. In this case, as you have noticed, there are some slight differences in the lockfile created by pnpm v10. We had to change the algorithm of some of the hashing methods. But these changes are backward compatible. We were considering bumping the lockfile to v9.1 but decided not to.

pnpm's definition of "backward compatible" is:

  1. The lockfile structure is readable by both versions (both can parse it)
  2. Checksums are recomputed on install (so mismatches self-heal)

This is a reasonable position for pnpm's use case. Normal pnpm install (without --frozen-lockfile) will recompute the checksums and rewrite the lockfile to match the running version's format. From pnpm's perspective, the lockfile is a cache that gets regenerated as needed.

The problem is that "backward compatible for reading" ≠ "backward compatible for --frozen-lockfile". When you use --frozen-lockfile, pnpm validates that the checksums match. Different hash algorithms → different checksums → ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.

pnpm's implicit contract is: if you use --frozen-lockfile, you must use the same pnpm version that generated the lockfile. They just don't say this explicitly.

What This Means for pm-core

For pm-core's purposes, pnpm 9.x and 10.x have different lockfile formats. The lockfileVersion: '9.0' field is shared, but the checksum algorithms are incompatible. Since pm-core's detection drives version selection, and version selection drives the assert operation (frozen install), we need to treat the hash format as a strong signal of the major version.

The hash format signals we've identified:

Signal 9.x Format 10.x Format
patchedDependencies hash 26-char base32 64-char SHA256 hex
packageExtensionsChecksum 32-char MD5 sha256-... base64
pnpmfileChecksum 32-char MD5 64-char SHA256 hex

Any of these signals is sufficient to constrain version selection. If we see a 26-char patchedDependencies hash, we must use pnpm 9.x. If we see a 64-char hash, we must use pnpm 10.x.

However, most lockfiles won't have any of these signals. A typical project with just dependencies and devDependencies will have lockfileVersion: '9.0' and nothing that distinguishes 9.x from 10.x. The v0 monorepo happens to have patchedDependencies (20+ patches), which is why we discovered this signal — but that's not representative.

For the ambiguous case (no checksum signals), narrowToLowerMajor (prefer 9.x) is the right heuristic for now. Our reasoning: in the absence of explicit pnpm@10 features, pnpm@9 is more likely to match what the user was actually using when they created the lockfile. But this investigation shows there are more ways than we initially thought to identify the exact pnpm version. We may discover cases where pnpm@10 is already the better choice, and find signals to represent those cases.

Additionally, v0's phasing plan includes inserting onlyBuiltDependencies allowlists into every v0-initiated app. Since allowlists are a pnpm@10 feature, this will give v0-created projects a strong @10 signal from the start. At that point, narrowToLowerMajor becomes less relevant for v0's own projects — they'll have an explicit signal.

Why Earlier Versions Mostly Worked

Our earlier experiments showed that pnpm 9.0.6 and 9.15.0 both succeed on lockfiles created by 9.15.0. This makes sense now: all 9.x versions use the same hash algorithm. The hash format is a major-version boundary, not a minor-version boundary.

Similarly, all 10.x versions (10.0.0, 10.6.1, etc.) use the same SHA256 format. The ERR_PNPM_LOCKFILE_CONFIG_MISMATCH error we saw with 10.0.0 wasn't a bug in 10.0.0's hash handling — it was the expected behavior when a 10.x version encounters a 9.x lockfile with checksummed fields.

(There was a separate bug in pnpm 10.0.0 where it ignored patchedDependencies from package.json entirely, fixed in 10.6.1. But that's orthogonal to the hash format incompatibility.)

Implications for pm-core

Why Our Previous Analysis Was Incomplete

We assumed:

lockfileVersion: '9.0' → pick pnpm 9.15.0

This is incomplete because:

  1. Both 9.x and 10.x write lockfileVersion: '9.0'
  2. The lockfile version doesn't distinguish which major created it
  3. We need additional signals: the hash format in patchedDependencies, packageExtensionsChecksum, or pnpmfileChecksum

The narrowToLowerMajor policy (prefer 9.x when ambiguous) was a reasonable heuristic, but it's not sufficient. A lockfile created by pnpm 10.x with checksummed fields cannot be used with pnpm 9.x in frozen mode, even though lockfileVersion says '9.0'. We need to detect the hash format and use it as a hard constraint, not just a preference.

The New Signals: Checksum Format Detection

pm-core needs to detect the hash format from any of the checksummed fields:

function detectPnpmMajorFromLockfile(lockfile: string): 9 | 10 | null {
  // Check patchedDependencies hash format
  const patchMatch = lockfile.match(/patchedDependencies:[\s\S]*?hash:\s*(\w+)/)
  if (patchMatch) {
    const hash = patchMatch[1]
    if (hash.length === 26) return 9 // base32, 9.x format
    if (hash.length === 64) return 10 // SHA256 hex, 10.x format
  }

  // Check packageExtensionsChecksum format
  const extMatch = lockfile.match(/packageExtensionsChecksum:\s*(\S+)/)
  if (extMatch) {
    const checksum = extMatch[1]
    if (checksum.startsWith('sha256-')) return 10 // 10.x format
    if (checksum.length === 32) return 9 // MD5, 9.x format
  }

  // Check pnpmfileChecksum format
  const pnpmfileMatch = lockfile.match(/pnpmfileChecksum:\s*(\w+)/)
  if (pnpmfileMatch) {
    const checksum = pnpmfileMatch[1]
    if (checksum.length === 32) return 9 // MD5, 9.x format
    if (checksum.length === 64) return 10 // SHA256 hex, 10.x format
  }

  return null // No checksummed fields, either version works
}

Any of these signals is sufficient. If none are present, the lockfile has no version-specific checksums and either 9.x or 10.x will work.

Why Main Worked

Main branch runs bare pnpm (whatever is on PATH: 9.0.6). Since 9.0.6 uses the same hash format as 9.15.0, it can read 9.x lockfiles with patchedDependencies.

Why pm-core Failed

  1. vm-lifecycle-manager.ts passes empty buffer for lockfile
  2. pm-core can't detect any signal → falls back to defaultContract('pnpm') = >=9.0.0
  3. resolveVersion() picks newest: 10.0.0
  4. pnpm 10.0.0 can't read 9.x hash format → ERR_PNPM_LOCKFILE_CONFIG_MISMATCH

The Two-Part Fix

Part 1: Read lockfile content (immediate fix)

Update detectPackageManagerAndLockfileFromPreview() to read actual lockfile content, not just check existence.

Part 2: Add checksum format detection (correct fix)

Add a new signal to pm-core that detects the checksum format from any of the checksummed fields (patchedDependencies, packageExtensionsChecksum, pnpmfileChecksum) and uses it to constrain version selection:

  • 9.x format (26-char base32 / 32-char MD5) → must use pnpm 9.x
  • 10.x format (64-char SHA256 / sha256- prefix) → must use pnpm 10.x
  • No checksummed fields → either version works (use narrowToLowerMajor policy)

This is a hard constraint, not a preference. The checksum format signal should override narrowToLowerMajor when present — if we see a 10.x checksum, we must use 10.x even though lockfileVersion is '9.0'.

ERR_PNPM_LOCKFILE_CONFIG_MISMATCH Investigation

Reference: pm-core-explainer.md for the conceptual model (signal hierarchy, coherence, the three-phase pipeline).

Summary

Root cause: vm-lifecycle-manager.ts passes an empty buffer for lockfile content to pm-core. Without lockfile content, pm-core can't detect signals and falls back to defaultContract('pnpm') = '>=9.0.0', which resolves to pnpm 10.0.0 (the newest available). pnpm 10.0.0 can't read lockfiles with 9.x-format checksums, causing ERR_PNPM_LOCKFILE_CONFIG_MISMATCH.

The fix has two parts:

  1. Read lockfile content — Update detectPackageManagerAndLockfileFromPreview() to read actual lockfile content, not just check existence.

  2. Add checksum format detection — Add a new signal to pm-core that detects the checksum format and uses it to constrain version selection. This is a hard constraint: 9.x checksums require pnpm 9.x, 10.x checksums require pnpm 10.x.

The Bug

After merging the pm-core branch, some v0 chat sessions fail with:

ERR_PNPM_LOCKFILE_CONFIG_MISMATCH

Cannot proceed with the frozen installation. The current "patchedDependencies"
configuration doesn't match the lockfile.

The failing sessions are from a user who imported the full v0 monorepo. The v0 monorepo has patchedDependencies (20+ patches), which creates checksummed fields in the lockfile.

Production Logs

pm-core branch (broken):

Scope: all 44 workspace projects
ERR_PNPM_LOCKFILE_CONFIG_MISMATCH  Cannot proceed with the frozen installation.
The current "patchedDependencies" configuration doesn't match the value found
in the lockfile
...
WARN  Unsupported engine: wanted: {"node":"22.x"}
      (current: {"node":"v24.13.0","pnpm":"10.0.0"})

main branch (working):

Lockfile mismatch detected, regenerating...
Installing dependencies with pnpm@9.0.6...

The key observation: pm-core ran pnpm 10.0.0, but main ran pnpm 9.0.6.

Root Cause: Empty Lockfile Buffer

Location: chat/app/chat/api/vm/modules/vm-lifecycle-manager.ts, line ~1689

if (foundFiles.some((f) => f.endsWith(lockfile))) {
  filesMap.set(lockfile, Buffer.from('')) // ← BUG: empty buffer!
  hasLockfile = true
  break
}

The function checks whether a lockfile exists but doesn't read its content. It passes an empty buffer to pm-core.

Why This Causes the Wrong Version

Without lockfile content:

analyzeProject(filesMap)
  → lockfile exists but content is empty
  → can't parse lockfileVersion or any checksums
  → falls back to defaultContract('pnpm') = '>=9.0.0'
  → resolveVersion() picks newest: 10.0.0

With lockfile content (what should happen):

analyzeProject(filesMap)
  → parses lockfileVersion: '9.0'
  → detects 9.x checksum format in patchedDependencies
  → constrains to pnpm 9.x
  → resolveVersion() picks 9.15.0

The pnpm 9/10 Compatibility Model

Empirically Verified (2026-02-17)

All tests run at /tmp/pnpm-test with a minimal project containing patchedDependencies.

Finding 1: lockfileVersion does NOT distinguish 9.x from 10.x

Both pnpm 9.x and 10.x write lockfileVersion: '9.0'. The lockfile version field is not a signal for distinguishing which major version created the lockfile.

Finding 2: Checksum format IS the distinguishing signal

Field 9.x Format 10.x Format
patchedDependencies hash 26-char base32 64-char SHA256 hex
packageExtensionsChecksum 32-char MD5 sha256-... base64
pnpmfileChecksum 32-char MD5 64-char SHA256 hex

Finding 3: All 9.x versions are compatible with each other

Tested: pnpm 9.0.0, 9.0.6, 9.15.0 all succeed on lockfiles created by any 9.x version. The hash algorithm is consistent within the major version.

$ pnpm@9.0.0 install --frozen-lockfile  # on 9.x lockfile
Lockfile is up to date, resolution step is skipped
Done in 404ms

$ pnpm@9.0.6 install --frozen-lockfile  # on 9.x lockfile
Lockfile is up to date, resolution step is skipped
Done in 454ms

Finding 4: All 10.x versions are compatible with each other

Tested: pnpm 10.0.0, 10.6.1 all succeed on lockfiles created by any 10.x version.

Finding 5: 9.x ↔ 10.x is incompatible for frozen install

When checksummed fields are present, crossing the major version boundary fails:

$ pnpm@10.0.0 install --frozen-lockfile  # on 9.x lockfile
ERR_PNPM_LOCKFILE_CONFIG_MISMATCH
The current "packageExtensionsChecksum" configuration doesn't match the value
found in the lockfile

$ pnpm@9.15.0 install --frozen-lockfile  # on 10.x lockfile
ERR_PNPM_LOCKFILE_CONFIG_MISMATCH

Finding 6: Fresh install (retry) always succeeds

Fresh install rewrites the lockfile to match the running version's checksum format. This is why retry "works" — it converts the lockfile.

Finding 7: Without checksummed fields, either version works

A lockfile with only dependencies and devDependencies (no patches, no packageExtensions, no pnpmfile) can be used with either 9.x or 10.x.

Why pnpm Didn't Bump lockfileVersion to 10

This is a deliberate choice. When a user asked exactly this question, zkochan (pnpm maintainer) explained:

The version in the lockfile is only bumped when there are some breaking changes to the format. It isn't necessarily bumped in every major release. In this case, as you have noticed, there are some slight differences in the lockfile created by pnpm v10. We had to change the algorithm of some of the hashing methods. But these changes are backward compatible. We were considering bumping the lockfile to v9.1 but decided not to.

pnpm's definition of "backward compatible":

  1. The lockfile structure is readable by both versions
  2. Checksums are recomputed on install (so mismatches self-heal)

The problem: "backward compatible for reading" ≠ "backward compatible for --frozen-lockfile". pnpm's implicit contract is: if you use --frozen-lockfile, you must use the same pnpm major version that generated the lockfile.

What This Means for pm-core

For pm-core's purposes, pnpm 9.x and 10.x have different lockfile formats when checksummed fields are present. Since detection drives version selection, and version selection drives the assert operation (frozen install), we need to treat the checksum format as a strong signal.

However, most lockfiles won't have any of these signals. A typical project with just dependencies and devDependencies will have lockfileVersion: '9.0' and nothing that distinguishes 9.x from 10.x.

For the ambiguous case (no checksum signals), narrowToLowerMajor (prefer 9.x) is the right heuristic for now. Our reasoning: in the absence of explicit pnpm@10 features, pnpm@9 is more likely to match what the user was actually using. But we may discover cases where pnpm@10 is already the better choice, and find signals to represent those cases.

Additionally, v0's phasing plan includes inserting onlyBuiltDependencies allowlists into every v0-initiated app. Since allowlists are a pnpm@10 feature, this will give v0-created projects a strong @10 signal from the start.

The Fix

Part 1: Read Lockfile Content

Update detectPackageManagerAndLockfileFromPreview() to read actual lockfile content:

// Instead of just checking existence with `ls`:
const result = await runCommand(this.sandbox, {
  cmd: 'sh',
  args: [
    '-c',
    `cat ${PREVIEW_CWD}/pnpm-lock.yaml 2>/dev/null || true`,
  ],
})

if (lockfileContent?.trim()) {
  filesMap.set('pnpm-lock.yaml', Buffer.from(lockfileContent.trim()))
}

Part 2: Add Checksum Format Detection

Add a new signal to pm-core that detects the checksum format:

function detectPnpmMajorFromLockfile(lockfile: string): 9 | 10 | null {
  // Check patchedDependencies hash format
  const patchMatch = lockfile.match(/patchedDependencies:[\s\S]*?hash:\s*(\w+)/)
  if (patchMatch) {
    const hash = patchMatch[1]
    if (hash.length === 26) return 9 // base32, 9.x format
    if (hash.length === 64) return 10 // SHA256 hex, 10.x format
  }

  // Check packageExtensionsChecksum format
  const extMatch = lockfile.match(/packageExtensionsChecksum:\s*(\S+)/)
  if (extMatch) {
    const checksum = extMatch[1]
    if (checksum.startsWith('sha256-')) return 10 // 10.x format
    if (checksum.length === 32) return 9 // MD5, 9.x format
  }

  // Check pnpmfileChecksum format
  const pnpmfileMatch = lockfile.match(/pnpmfileChecksum:\s*(\w+)/)
  if (pnpmfileMatch) {
    const checksum = pnpmfileMatch[1]
    if (checksum.length === 32) return 9 // MD5, 9.x format
    if (checksum.length === 64) return 10 // SHA256 hex, 10.x format
  }

  return null // No checksummed fields, either version works
}

This is a hard constraint, not a preference. If we see a 10.x checksum, we must use pnpm 10.x even though lockfileVersion is '9.0'.

Why Main Worked

Main branch runs bare pnpm (whatever is on PATH: 9.0.6). The v0 monorepo's lockfile has 9.x-format checksums. Since 9.0.6 uses the same checksum algorithm as all other 9.x versions, it can read the lockfile.

Both main and pm-core fail the frozen install (the lockfile is stale after import), but both retry with fresh install. The difference:

  • Main: 9.0.6 fresh install succeeds, rewrites lockfile with 9.x checksums
  • pm-core: 10.0.0 fresh install succeeds, rewrites lockfile with 10.x checksums

Both "work" in the sense that the install completes. But pm-core's behavior is worse because it silently converts the lockfile to a different format.

Testing Gap

The property tests pass real lockfile content directly to detectPackageManagerFromFiles(), bypassing vm-lifecycle-manager. The vm-lifecycle-manager tests mock away detection entirely. Neither caught the empty buffer bug.

Missing: Integration test that verifies detectPackageManagerAndLockfileFromPreview() passes real lockfile content to pm-core and gets the expected resolved version.


Appendix: Raw Production Logs

"With" run (pm-core branch)

https://v0.app/chat/hello-world-RcPinlKvfWP

Scope: all 44 workspace projects
10:06:21.662Z
[SERVER]
 ERR_PNPM_LOCKFILE_CONFIG_MISMATCH  Cannot proceed with the frozen installation.
 The current "patchedDependencies" configuration doesn't match the value found
 in the lockfile

Update your lockfile using "pnpm install --no-frozen-lockfile"
10:06:21.670Z
[SERVER]
Lockfile mismatch detected, regenerating...
10:06:21.674Z
[SERVER]
Installing dependencies...
10:06:22.234Z
[SERVER]
 WARN  Issue while reading "/vercel/share/v0-next-shadcn/chat/.npmrc".
       Failed to replace env in config: ${VERCEL_PRIVATE_REGISTRY_TOKEN}
 WARN  Issue while reading "/vercel/share/v0-next-shadcn/.npmrc".
       Failed to replace env in config: ${VERCEL_PRIVATE_REGISTRY_TOKEN}
10:06:22.295Z
[SERVER]
..
 WARN  Unsupported engine: wanted: {"node":"22.x"}
       (current: {"node":"v24.13.0","pnpm":"10.0.0"})

"Without" run (main branch)

https://v0.app/chat/vercel-v0-s2dwoBASKy1

Lockfile mismatch detected, regenerating...
10:08:34.044Z
[SERVER]
Installing dependencies...
10:08:34.547Z
[SERVER]
 WARN  Issue while reading "/vercel/share/v0-next-shadcn/chat/.npmrc".
       Failed to replace env in config: ${VERCEL_PRIVATE_REGISTRY_TOKEN}
 WARN  Issue while reading "/vercel/share/v0-next-shadcn/.npmrc".
       Failed to replace env in config: ${VERCEL_PRIVATE_REGISTRY_TOKEN}
10:08:34.693Z
[SERVER]
Installing dependencies with pnpm@9.0.6...
#!/usr/bin/env bash
set -euo pipefail
# =============================================================================
# Reproduction Environment Setup
# ERR_PNPM_LOCKFILE_CONFIG_MISMATCH Investigation
#
# See: packages/pm-core/pm-core-explainer.md for the conceptual model
# See: docs/investigations/patchedDependencies-mismatch-notebook.md for context
#
# This script sets up an isolated clone of the v0 monorepo for testing.
# The v0 monorepo is a valid test case: it builds on Vercel platform, so
# pm-core should be able to find a working command sequence.
#
# Prerequisites:
# - Node.js 22+ (24+ for native TS execution)
# - proto (https://moonrepo.dev/proto) for running specific pnpm versions
# - Valid npm auth (run `pnpm login` if needed)
#
# Usage:
# ./docs/investigations/repro-setup.sh
#
# What it creates:
# /tmp/v0-repro/ — git clone of the v0 monorepo
# /tmp/corepack-install/ — local corepack installation
# =============================================================================
REPRO_DIR="/tmp/v0-repro"
COREPACK_DIR="/tmp/corepack-install"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
echo "=== Setting up reproduction environment ==="
echo " Repo root: $REPO_ROOT"
echo " Repro dir: $REPRO_DIR"
echo ""
# ── Step 1: Clone the monorepo ──────────────────────────────────────────────
# Use git clone for a clean isolated copy (shares objects, fast)
if [[ -d "$REPRO_DIR" ]]; then
echo " $REPRO_DIR already exists."
echo " To start fresh: rm -rf $REPRO_DIR && re-run"
else
echo " Cloning monorepo to $REPRO_DIR..."
git clone --no-checkout "$REPO_ROOT" "$REPRO_DIR"
cd "$REPRO_DIR"
git checkout HEAD -- .
echo " Done."
fi
echo ""
# ── Step 2: Install corepack ────────────────────────────────────────────────
# pm-core uses corepack to invoke specific pnpm versions
if [[ -x "$COREPACK_DIR/node_modules/.bin/corepack" ]]; then
echo " corepack already installed at $COREPACK_DIR."
else
echo " Installing corepack to $COREPACK_DIR..."
mkdir -p "$COREPACK_DIR"
pushd "$COREPACK_DIR" > /dev/null
proto run npm 11.10.0 -- init -y 2>/dev/null
proto run npm 11.10.0 -- install corepack 2>&1 | tail -3
popd > /dev/null
echo " Done."
fi
COREPACK="$COREPACK_DIR/node_modules/.bin/corepack"
echo " corepack version: $($COREPACK --version)"
echo ""
# ── Step 3: Verify the monorepo state ───────────────────────────────────────
echo "=== Monorepo state ==="
cd "$REPRO_DIR"
echo " lockfileVersion: $(head -1 pnpm-lock.yaml)"
echo " packageManager: $(node -e "console.log(require('./package.json').packageManager)")"
echo " patchedDependencies: $(node -e "console.log(Object.keys(require('./package.json').pnpm?.patchedDependencies || {}).length)") in package.json"
echo " pnpm-workspace.yaml: $(test -f pnpm-workspace.yaml && echo 'present' || echo 'missing')"
echo " .npmrc: $(test -f .npmrc && echo 'present' || echo 'absent')"
echo ""
# ── Step 4: Print next steps ────────────────────────────────────────────────
cat <<EOF
=== Ready ===
Next: Run the trace script to see what commands pm-core generates:
cd /tmp/v0-repro
node $REPO_ROOT/docs/investigations/repro-trace.ts
The trace script will:
1. Call pm-core's analyzeProject() with the monorepo files
2. Show the detection results (signals, candidates, warnings)
3. Call getSetupCommand() to see the actual commands pm-core generates
4. Compare to what main branch would run
This tests hypothesis H2 (different commands) directly.
EOF
/**
* Trace pm-core's detection and command generation against the current directory.
*
* See: packages/pm-core/pm-core-explainer.md for the conceptual model
*
* This script answers hypothesis H2: "Does pm-core generate different commands
* than main?" by actually calling pm-core's APIs:
*
* 1. Reads project files (same as v0's detectPackageManagerFromFiles)
* 2. Calls analyzeProject() to get detection results
* 3. Shows candidates, warnings, and conflicts
* 4. Calls getSetupCommand() for both 'frozen' and 'fresh' intents
* 5. Compares to what main branch would run
*
* Run from the reproduction monorepo clone:
* cd /tmp/v0-repro && node <repo>/docs/investigations/repro-trace.ts
*
* Requires: Node 24+ (native TS execution), pm-core built
*/
import { readFileSync, existsSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const projectRoot = process.cwd()
const repoRoot = join(__dirname, '../..')
// ── Step 1: Import pm-core ──────────────────────────────────────────────────
console.log('=== Loading pm-core ===\n')
let analyzeProject: typeof import('@vercel/pm-core').analyzeProject
let getSetupCommand: typeof import('@vercel/pm-core').getSetupCommand
let ALL_RELEVANT_FILES: typeof import('@vercel/pm-core').ALL_RELEVANT_FILES
try {
// Import from source (Node 24+ has native TS support)
const pmCore = await import(join(repoRoot, 'packages/pm-core/src/index.ts'))
analyzeProject = pmCore.analyzeProject
getSetupCommand = pmCore.getSetupCommand
ALL_RELEVANT_FILES = pmCore.ALL_RELEVANT_FILES
console.log(' ✓ pm-core loaded from src/\n')
} catch (e) {
console.log(' ✗ Failed to load pm-core.\n')
console.log(` Error: ${e}\n`)
process.exit(1)
}
// ── Step 2: Gather files ────────────────────────────────────────────────────
console.log('=== File Discovery ===\n')
const filesMap = new Map<string, Buffer>()
for (const file of ALL_RELEVANT_FILES) {
const fullPath = join(projectRoot, file)
if (existsSync(fullPath)) {
const buf = readFileSync(fullPath)
filesMap.set(file, buf)
console.log(` ✓ ${file} (${buf.length} bytes)`)
}
}
console.log(`\n Total: ${filesMap.size} files\n`)
// ── Step 3: Call analyzeProject() ───────────────────────────────────────────
console.log('=== analyzeProject() Results ===\n')
const result = analyzeProject(filesMap, { strictness: 'warn' })
if (!result.ok) {
console.log(` ✗ Analysis failed: ${result.error}\n`)
process.exit(1)
}
const analysis = result.value
console.log(' Package Manager Candidates:')
for (const c of analysis.packageManager) {
console.log(` ${c.quality}: ${c.value} (from ${c.source})`)
}
console.log('\n Version Constraint Candidates:')
for (const c of analysis.constraint) {
console.log(` ${c.quality}: ${c.value} (from ${c.source})`)
}
if (analysis.lockfile) {
console.log(
`\n Lockfile: type=${analysis.lockfile.type}, version=${analysis.lockfile.version}`,
)
}
console.log(`\n hasBuildScriptPolicy: ${analysis.hasBuildScriptPolicy}`)
if (analysis.warnings.length > 0) {
console.log('\n Warnings:')
for (const w of analysis.warnings) {
console.log(` - ${w.code}: ${w.message}`)
}
}
if (analysis.conflicts.length > 0) {
console.log('\n Conflicts:')
for (const c of analysis.conflicts) {
console.log(` - ${c.explanation}`)
}
}
// ── Step 4: Simulate v0's resolveVersion() ──────────────────────────────────
console.log('\n=== Version Resolution (v0 adapter) ===\n')
// v0's AVAILABLE_VERSIONS from chat/app/chat/api/vm/modules/package-manager/index.ts
const AVAILABLE_VERSIONS = {
pnpm: ['6.35.1', '7.33.7', '8.15.9', '9.15.0', '10.0.0'],
npm: ['10.9.2'],
yarn: ['1.22.22', '4.6.0'],
bun: ['1.2.4'],
}
const pm = analysis.packageManager[0]?.value ?? 'pnpm'
const constraint = analysis.constraint[0]?.value ?? '>=9.0.0'
console.log(` Detected PM: ${pm}`)
console.log(` Top constraint: ${constraint}`)
console.log(
` Available ${pm} versions: [${AVAILABLE_VERSIONS[pm as keyof typeof AVAILABLE_VERSIONS].join(', ')}]`,
)
// Simplified resolveVersion (walks newest→oldest, picks first satisfying)
function resolveVersion(pmName: string, versionConstraint: string): string {
const versions =
AVAILABLE_VERSIONS[pmName as keyof typeof AVAILABLE_VERSIONS] ?? []
const constraintMatch = versionConstraint.match(/>=([\d.]+).*<([\d.]+)/)
if (!constraintMatch) return versions[versions.length - 1] ?? '0.0.0'
const lo = constraintMatch[1]!.split('.').map(Number)
const hi = constraintMatch[2]!.split('.').map(Number)
for (let i = versions.length - 1; i >= 0; i--) {
const v = versions[i]!.split('.').map(Number)
const major = v[0]!
if (major >= lo[0]! && major < hi[0]!) {
return versions[i]!
}
}
return versions[versions.length - 1] ?? '0.0.0'
}
const resolvedVersion = resolveVersion(pm, constraint)
console.log(`\n Resolved: ${pm}@${resolvedVersion}`)
// ── Step 5: Call getSetupCommand() ──────────────────────────────────────────
console.log('\n=== getSetupCommand() Results ===\n')
const resolved = {
pm: pm as 'pnpm' | 'npm' | 'yarn' | 'bun',
version: resolvedVersion,
}
// Frozen install (coherence: 'assert')
const frozenResult = getSetupCommand(resolved, {
coherence: 'assert',
install: true,
preferOffline: true,
runAllBuildScripts: !analysis.hasBuildScriptPolicy,
})
console.log(' Frozen install (coherence: assert):')
if (frozenResult.ok && frozenResult.value.args) {
console.log(
` corepack ${pm}@${resolvedVersion} ${frozenResult.value.args.join(' ')}`,
)
} else {
console.log(` ✗ ${frozenResult.ok ? 'null args' : frozenResult.error}`)
}
// Fresh install (coherence: 'update') — the retry path
const freshResult = getSetupCommand(resolved, {
coherence: 'update',
install: true,
preferOffline: true,
runAllBuildScripts: !analysis.hasBuildScriptPolicy,
})
console.log('\n Fresh install (coherence: update) — retry path:')
if (freshResult.ok && freshResult.value.args) {
console.log(
` corepack ${pm}@${resolvedVersion} ${freshResult.value.args.join(' ')}`,
)
} else {
console.log(` ✗ ${freshResult.ok ? 'null args' : freshResult.error}`)
}
// ── Step 6: Compare to main branch ──────────────────────────────────────────
console.log('\n=== Comparison: pm-core vs main ===\n')
console.log(' pm-core branch would run:')
if (frozenResult.ok && frozenResult.value.args) {
console.log(
` 1. corepack ${pm}@${resolvedVersion} ${frozenResult.value.args.join(' ')}`,
)
console.log(
` 2. If exit != 0: corepack ${pm}@${resolvedVersion} ${freshResult.ok && freshResult.value.args ? freshResult.value.args.join(' ') : '(error)'}`,
)
}
console.log('\n main branch would run:')
console.log(
' 1. bash -c "echo \'Installing with pnpm@$(pnpm --version)...\' && pnpm install --frozen-lockfile --prefer-offline"',
)
console.log(' 2. If exit != 0: pnpm install --prefer-offline')
console.log(' (bare pnpm on PATH → 9.0.6 in sandbox)')
// ── Step 7: Key differences ─────────────────────────────────────────────────
console.log('\n=== Key Differences ===\n')
console.log(` Version: pm-core uses ${resolvedVersion}, main uses 9.0.6`)
console.log(' Invocation: pm-core uses corepack, main uses bare pnpm')
if (frozenResult.ok && frozenResult.value.args) {
const hasRunAllBuilds = frozenResult.value.args.includes(
'--dangerously-allow-all-builds',
)
if (hasRunAllBuilds) {
console.log(
' Build scripts: pm-core adds --dangerously-allow-all-builds (pnpm 10 default blocks scripts)',
)
}
}
// ── Step 8: Hypothesis check ────────────────────────────────────────────────
console.log('\n=== Hypothesis H2 Check ===\n')
console.log(' Q: Does pm-core generate different commands than main?')
console.log(
` A: Yes — version (${resolvedVersion} vs 9.0.6) and invocation method differ.`,
)
console.log('')
console.log(' Next: Verify the retry path actually fires in pm-core (H3).')
console.log(
' Check: chat/app/chat/api/vm/modules/package-manager.ts installDependencies()',
)
console.log('')
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment