Reference: pm-core-explainer.md for the conceptual model (signal hierarchy, coherence, the three-phase pipeline).
We want v0 to do less bespoke shit. This means:
- Align with Vercel platform behavior: if a project builds on platform, it should build on v0.
- 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:
- Main worked (even if we don't know exactly why)
- pm-core broke it (we have bug reports)
- 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.
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.
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.
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.
| 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 |
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:
- What command does main run? (version, flags, retry behavior)
- What command does pm-core run? (version, flags, retry behavior)
- What command does platform run? (the ground truth)
- Where do they diverge?
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
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.
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 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.
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.
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.
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:
// _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).
// _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.
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.
⚠️ PRESERVE THESE LOGS — they are the primary evidence. Do not summarize or paraphrase. If context is lost, ask for the original logs again.
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:
- The deployed code differs from what we analyzed
- There's a code path that bypasses
narrowToLowerMajor() - We're misunderstanding something about the resolution flow
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"})
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}
"With" run observations:
- No
"Installing dependencies with pnpm@10.0.0..."echo — the pm-core branch changed the install command frombash -c "echo ... && pnpm install ..."tocorepack pnpm@10.0.0 install ..., so the version announcement is gone. The engine warning revealspnpm: "10.0.0". Scope: all 44 workspace projectsappears before the error — pnpm discovers the imported monorepo'spnpm-workspace.yaml.- The frozen install fails with
ERR_PNPM_LOCKFILE_CONFIG_MISMATCH. - Retry fires ("Lockfile mismatch detected, regenerating..."), then "Installing dependencies..." (the fresh install).
.npmrcwarnings referencePREVIEW_CWD/chat/.npmrcandPREVIEW_CWD/.npmrc— these are the v0 monorepo's own.npmrcfiles.- Engine warning: sandbox runs Node 24.13.0 but monorepo wants
node: "22.x".
"Without" run observations:
- The frozen install's
ERR_PNPM_LOCKFILE_CONFIG_MISMATCHis not visible in the logs — either truncated or the old code's piped-through-bash output doesn't surface it the same way. - "Lockfile mismatch detected, regenerating..." appears first — the retry.
- "Installing dependencies..." — the
appendVMLogmessage for the fresh install. - "Installing dependencies with pnpm@9.0.6..." — the bash echo from the old
install command (
echo "... with pnpm@$(pnpm --version)..." && pnpm install). .npmrcwarnings appear THREE times — once during the retry's fresh install, and twice more during what appears to be a second install pass.
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.
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
| 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:
- Deployed code differs: The production deployment doesn't have
narrowToLowerMajor(maybe an older version?) - Code path we missed: There's a branch in the resolution logic we haven't traced
- Test doesn't match production: Our unit test doesn't accurately simulate the production flow
- Verify deployed code: Check if the production pm-core branch has
narrowToLowerMajorinresolveContract() - Trace full resolution path: Add logging or write a test that exactly mirrors the production call sequence
- Get full production logs: The current logs cut off — we need to see the final exit code and whether the session loaded
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
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
| 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
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.
The production logs cut off. We don't know if the fresh install succeeded or failed, or what the final session state was.
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.
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 logs show:
- pm-core branch: Frozen fails → retry fires → fresh install starts → ???
- main branch: Frozen fails → retry fires → fresh install starts → succeeds
Status:
If pm-core resolves correctly (9.15.0), generates correct commands, and retry fires, why does production fail? Possibilities:
- Production code differs from what we analyzed (deployment timing?)
- Environment difference (corepack version, PATH, sandbox state)
- The failure is in the fresh install, not the frozen install
- Something in the logs we're misreading
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.tsrepro-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).
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-offlineExperiment 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-offlineExperiment C: main path (bare pnpm 9.0.6, frozen)
proto run pnpm 9.0.6 -- install --frozen-lockfile --prefer-offlineExperiment D: main path (bare pnpm 9.0.6, fresh retry)
proto run pnpm 9.0.6 -- install --no-frozen-lockfile --prefer-offlineExperiment E: correct resolution (corepack pnpm@9.15.0, frozen)
COREPACK_ENABLE_STRICT=0 $COREPACK pnpm@9.15.0 install --frozen-lockfile --prefer-offline| 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) |
- pnpm 9.15.0 frozen succeeds where 9.0.6 and 10.0.0 fail
- Both 9.0.6 and 10.0.0 succeed on fresh retry
- The
patchedDependenciesmismatch error is version-specific
-
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.
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.
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
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.patchThe 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.
| 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 |
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.
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.0However, fixing the empty buffer bug is the primary fix. The defaultContract
question is secondary — it only matters when there's truly no lockfile.
pm-core's design is correct:
- Detect lockfile version → derive constraint → pick compatible version
- The
narrowToLowerMajorpolicy 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-managerdoesn't pass lockfile content to pm-core- pm-core falls back to unbounded constraint
- Resolves to 10.0.0 instead of 9.15.0
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.
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.
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
})| 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.
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()))
}Add an integration test that:
- Sets up a mock sandbox with realistic file content
- Calls
detectPackageManagerAndLockfileFromPreview() - Verifies the resolved version matches pm-core's expected output
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".
Create an integration test matrix that:
-
Generates realistic repo scenarios programmatically:
- Various lockfile versions (pnpm 7.0, 9.0, npm v3, yarn berry, etc.)
- With/without
packageManagerfield - With/without
enginesconstraints - Monorepo vs single-package
- With/without pnpm-workspace.yaml
-
Exercises the full detection path:
- Mock sandbox that returns file content (not just existence)
- Call
detectPackageManagerAndLockfileFromPreview() - Verify resolved version against expected value
-
Validates against ground truth:
- What version would platform use?
- What version would a "normal dev machine" use?
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.
Created a minimal test case at /tmp/pnpm-test to empirically validate the
patchedDependencies compatibility hypothesis. All tests run on 2026-02-17.
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.patchpnpm 10.x creates long hashes (64 chars, SHA256 hex):
patchedDependencies:
is-odd@1.0.0:
hash: 5b663d9ac9d517a8a958e72d21bb55f75fb3cef2abd1dc474099b97dd1e67b4a
path: patches/is-odd@1.0.0.patchv0's lockfile uses the 9.x format (confirmed):
patchedDependencies:
'@alexandernanberg/react-pdf-renderer@4.0.0-canary-2':
hash: jeapvpqkhq3urj322bknzhtwim # ← 26 chars, 9.x formatBoth 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.
| 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.
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.patchThe 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.
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.
| 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.
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:
- The lockfile structure is readable by both versions (both can parse it)
- 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.
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.
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.)
We assumed:
lockfileVersion: '9.0'→ pick pnpm 9.15.0
This is incomplete because:
- Both 9.x and 10.x write
lockfileVersion: '9.0' - The lockfile version doesn't distinguish which major created it
- We need additional signals: the hash format in
patchedDependencies,packageExtensionsChecksum, orpnpmfileChecksum
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.
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.
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.
vm-lifecycle-manager.tspasses empty buffer for lockfile- pm-core can't detect any signal → falls back to
defaultContract('pnpm')=>=9.0.0 resolveVersion()picks newest: 10.0.0- pnpm 10.0.0 can't read 9.x hash format → ERR_PNPM_LOCKFILE_CONFIG_MISMATCH
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
narrowToLowerMajorpolicy)
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'.