fix(startup): recover plugin failures and preserve both Market update paths - #380
Merged
Conversation
`dshmarket` is a core bundle the profile migration deliberately keeps in the shared hoisted tree (`KEEP_IN_SHARED_TREE`), so its entry under `profiles/web/node_modules` is a real directory, never a generation link. Since #352, `runExternalMarketPluginInstall` routes every `add` — market self-updates included — through `installGeneration` + the immediate `publishInstalledGeneration`, which refuses a non-link directory by design: Cannot switch a non-link plugin directory: .../node_modules/dshmarket So every `dshmarket` self-update failed, and because it throws before any move, the market's rollback check also reports the previous build could not be verified. Before #352 the boundary staged the switch for the next cold start (`exposeMissingGenerationLinks` no-op'd on an existing entry), so the update eventually landed; #352 turned that into a hard failure. Branch on the spec: a `dshmarket` package goes through the ordinary shared-tree `add --workspace-root`, which updates the directory in place — the same asymmetry #351 already fixed on the uninstall side. The market's registry is still resolved and pinned (#337), and any stale dshmarket generation id is pruned from `desired.json`. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
#352 replaced the market install boundary's "staged for next restart" publish step with publishInstalledGeneration(), which switches the profile node_modules entry synchronously while Harness is running. For any entry that is already in use that recreates the Windows locked-rename conflict the generation model exists to avoid: - dshmarket is a core bundle kept in the shared tree, so its entry is a real directory. publishInstalledGeneration() refuses a non-link entry: every market self-update failed with "Cannot switch a non-link plugin directory", and the market's rollback check then reported the previous build could not be verified. - a loaded generation junction cannot be renamed on Windows, so a plugin update mid-session hangs on the rename retry — the market UI sits on "in progress" until the registry lock times out. Restore the pre-#352 behavior: install the new generation, point desired.json at it, expose only a brand-new link for the market's add validation, and let the next cold start's projection switch the entry while Harness is stopped. The #337 registry resolution added by #352 is kept — only the immediate-switch step is reverted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
generation-migration.ts already keeps dshmarket in the shared profile
tree forever (KEEP_IN_SHARED_TREE) — it is a core bundle, never meant to
be a generation. Two recent paths broke that invariant by installing it
as one anyway:
- runExternalMarketPluginInstall only routed dshmarket to the shared-tree
updateSharedMarket() when its node_modules entry was NOT currently a
symlink; once anything made it one, later "add" calls fell through to
installGeneration()/publishInstalledGeneration() like any third-party
plugin.
- ensureMarketBaseline's default upgrader (upgradePluginToGeneration)
installed the verified baseline as a generation and published it as a
link, for dshmarket specifically.
Once dshmarket became a generation, whether it stayed a real directory or
a symlink depended on whichever code path touched it last — and a build
that reached the live profile that way (dshmarket 1.38.0, missing a host
`dsh-settings` export) could not be detected or rolled back afterward:
plugin-recovery excludes core bundles from its candidate list by design,
so the crash showed "plugin recovery detection: unresolved" with no way
to fix it short of hand-editing the profile.
Close both paths:
- runExternalMarketPluginInstall always routes a dshmarket spec to
updateSharedMarket(), which now also drops a stale generation symlink
(leaving the generation directory itself untouched) before running
pnpm, so a previously-projected Market collapses back to a real
directory instead of being treated as an ordinary generation update.
- plugin-upgrade.ts gains upgradeMarketInSharedTree(), which cleans up
any stale generation ownership/override, drops a stale symlink, and
reinstalls the whole shared profile tree via
installProfileDependenciesWithDsh() (Harness already stopped at this
point in startup) instead of installGeneration(). market-baseline.ts
now defaults to it, and also repairs a dshmarket entry that is a
generation link even when its version already meets the baseline —
the version check alone can't see that shape and would otherwise leave
it a generation forever.
upgradePluginToGeneration() itself is unchanged and still correctly used
for ordinary third-party plugin upgrades (recovery-page and Safe Mode
paths), which are unaffected — isThirdPartyPackageName()/CORE_BUNDLES
already exclude dshmarket from those candidate lists.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Contributor
Author
追加修复:dshmarket 彻底不进 generation 系统上面几版修复之后,本地复现了一个新问题: 根因:
第三方插件的升级路径( 新增/修改测试: 🤖 Generated with Claude Code |
38be336 closed the two writers that could install dshmarket as a generation, but not the projector that recreated one on every launch. `desired.json` still held a `dshmarket+1.38.0` pointer, and `resolveEnabledGenerations` enables whatever it lists — so `projectGenerations` re-linked the broken build each boot, ahead of any repair. The symlink's mtime after installing the previous fix is the proof: it was rewritten minutes later, by the fixed build itself. Two failure modes compounded: - projection ran before the market repair, so the repair could only ever clean up after it; and - the repair sat behind `shouldDeferProfileMaintenance`, which is true while any plugin removal lacks `bootVerifiedAt` — a flag only a successful boot sets. A market that cannot load is what stops the boot, so the gate could never clear itself: the repair needed a boot, the boot needed the repair. Enforce the invariant where it cannot be outrun, and let the repair run early enough to matter: - `resolveEnabledGenerations` skips shared-tree-only packages, so a stray pointer is inert instead of a link. Nothing can project dshmarket, whatever `desired.json` says. - `demoteMarketGeneration` undoes a legacy projection before projection runs: link, pointer, ownership marker and override all go, while the `dependencies` declaration and `bundles` entry stay — dropping those reads as "the market was uninstalled" and every later repair declines to reinstall it. - both that and `ensureMarketBaseline` now run ahead of projection and ahead of the removal-verification gate. A frozen migration still blocks them; that path keeps the profile byte-for-byte for rollback. - `pnpmRunnerPath` reaches `installProfileDependenciesWithDsh`. Without it `ensureProfilePnpmShim` refuses to run a plain pnpm against a profile that still projects ordinary plugins, which failed the install with "the generation-aware pnpm runner is unavailable". The option is required rather than optional so the compiler catches the next omission. Verified on a profile in exactly the reported state — a 1.38.0 market generation plus an unverified removal — which now self-heals to a real 1.45.1 directory in the shared tree on the next launch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Sep 11, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
启动报错此前依赖日志文字猜测插件归属,更新检查又只看 latest,无法选择仍兼容当前 DSH 的中间版本。本 PR 在加载和启动异常中保留插件归属,并从发布版本中选择符合宿主声明的升级目标。
Market 的共享目录与普通插件的 generation 链接采用各自的更新流程:共享目录 Market 通过原生 DSH CLI / pnpm 更新,并校验实际版本;generation 插件(包括已投影的 Market)在返回成功前切换 Profile 链接,保留旧 generation 供当前进程使用。Windows 只移除旧 junction 指针,不重命名已加载的目标目录。切换失败恢复旧链接及 desired 状态,兼容旧版暂存留下的 Market 元数据,避免覆盖其他插件。
正常启动在恢复门禁和 generation 投影完成后检查 Market 的实际安装版本。低于已验证基线 1.45.1 时,在 Harness 停止期间安装并验证新版本;保留更高版本以及用户卸载、禁用状态。启动时替换旧实目录同样支持失败回滚。
验证:最终隔离工作区 813 项测试、typecheck、build 通过。临时 Profile 使用真实 DSH CLI + pnpm 完成共享 Market 1.45.1 安装,官方 Market 版本读取确认成功,普通插件链接及后续启动投影保持正确。Windows CI(0829995,run 34555164267)全部通过,包括 junction 更新/失败回滚测试、类型检查、构建、三种缩放恢复界面检查、开发安装包及打包后 Harness 启动冒烟测试。未对用户当前安装的应用和实际 Profile 做手工验收。