fix(plugin-store): reject an empty SDK tree without failing the healthy shape (BLO-31857) - #1690
fix(plugin-store): reject an empty SDK tree without failing the healthy shape (BLO-31857)#1690allyblockcast[bot] wants to merge 1 commit into
Conversation
…hy shape (BLO-31857) `checkSharedDependencyConsistency` treated absence on *either* side as making no claim, so `(lock absent)/(installed absent)` — nothing recorded, nothing installed — came back congruent. A boot fixture that set up no store at all therefore passed activation while asserting nothing, which is how `plugin-database.test.ts` came to drive `loadSingle` against the real `~/.paperclip/plugins` with no SDK present anywhere on disk. Reject that one shape, as `problem: "not_installed"`, and keep both single-sided absences congruent: - `(lock absent)/(installed ok)` is the measured healthy steady state of every isolated install dir in production (live PVC 2026-09-04): a peer-dependency-only plugin records no direct dependency, so npm locks nothing while still installing the peer. Since activation throws on `!consistent` before spawning a worker, rejecting it would fail `@lucitra/paperclip-plugin-secrets`, `@lucitra/paperclip-plugin-chat` and `@penstock/paperclip-plugin` simultaneously. - `(lock ok)/(installed absent)` stays congruent because the boot-time install race already owns it via SDK_INSTALL_RACE_RETRY_DELAYS_MS. A transient `(absent)/(absent)` during the concurrent boot install stays safe: every inconsistent result routes through `checkSharedDependencyConsistencyAfterRecheck`, which returns the moment the SDK lands and only fails closed past the min-stable window. The refusal deliberately does not reuse TORN_STORE_ERROR_MARKER — `reconcileLegacyIsolatedInstallsAtStartup` un-latches rows carrying it, which would revive an empty tree into the same failure next boot. No resolvability assertion is added. Both candidate probes were measured against the live layout and fail on known-good trees (`require.resolve` throws ERR_PACKAGE_PATH_NOT_EXPORTED — the SDK's `exports` map is ESM-only; `import.meta.resolve` failed on all four targets including hindsight), so either would fail closed for every plugin on the box. Recorded in a comment at the guard so it is not added later without re-measuring. Co-Authored-By: Claude <noreply@anthropic.com>
|
@ally please review at head 41ca614. Focus, in priority order:
|
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 41ca614
The core judgement is right, and the part that is easiest to get wrong here is done well: (lock absent)/(installed ok) stays congruent, and it is pinned by seven tests plus an end-to-end activation test, so the outage this narrowly avoids cannot be reintroduced silently. The two findings below are both about the blast radius of the new not_installed verdict rather than about the verdict itself.
Critical Issues (0)
Important Issues (2)
-
[gstack/review + native-codex]
server/src/services/plugin-loader.ts:472— the newnotInstalledverdict is unscoped, so it also fires for every non-isolated plugin, whose consistency check runs against the shared store rather than a dir the server installed into.plugin-loader.ts:2866resolvespluginInstallDir = activePlugin.installDir ?? localPluginDir, and local-filesystem/cwd/node_modulesdiscovery deliberately persistsinstallDir = null(:2336,:2472). For a plugin loaded from the repo's ownnode_modules, the SDK resolves by walking up to the workspace root —~/.paperclip/pluginsis never consulted at import time and may legitimately be(absent)/(absent). That tree previously activated fine; it now fails closed after the full ~28.5s recheck.- The PR's own diff is the evidence that this path was live and working:
plugin-database.test.tsneeded the newcreateSdkPopulatedPluginStore()fixture because activation had been succeeding with a completely empty store. Calling that fixture vacuous is fair — but the same shape in a dev checkout is a working setup, not a vacuous one, and it is not covered by either the measured production shape or the new tests. - Recommendation: scope the verdict to install dirs the server owns — gate
notInstalled(or the throw at:2900) onactivePlugin.installDir !== null/isIsolatedSdkPluginPackage(packageName). That keeps the guard exactly where BLO-31857 was measured (isolated dirs the server npm-installs) and still rejects the empty-isolated-tree case, without failing a tree that resolves the SDK by walking up.
-
[pr-review-toolkit/errors]
server/src/services/plugin-loader.ts:557— decoupling fromTORN_STORE_ERROR_MARKERis correctly reasoned, but it leavesnot_installedwith no automatic recovery path at all, and the docstring does not say so.reconcileLegacyIsolatedInstallsAtStartupskips any errored row that does not carry the torn marker (:1899), and even for torn rows only clears status wheninstallDiractually moved (:1903).registry.updateStatus(... "ready" ...)at:1906is the only error→ready transition in the file, so a row latched by the new marker stays errored until an operator intervenes.- That makes the 28.5s recheck the single line of defence for a transient. The sibling shape
(lock ok)/(installed absent)gets a second independent budget — theSDK_INSTALL_RACE_RETRY_DELAYS_MSworker-spawn retry at:3014— but(absent)/(absent)now throws before a worker is ever spawned, so it never reaches that loop. The shape with strictly less information gets strictly less resilience, and a bootnpm installslower than ~28.5s (cold cache, slow PVC) permanently disables the plugin. The new test asserting the transient settles only proves the fast case. - Recommendation: add
SDK_NOT_INSTALLED_ERROR_MARKERto the startup un-latch set with a re-probe rather than a relocation test — clear toreadyiffcheckSharedDependencyConsistencynow returnsconsistent, independent of whetherinstallDirchanged. That preserves the intent ("an empty tree needs an actual install") while letting the row recover once the install lands, instead of requiring a human.
Suggestions (3)
- [pr-review-toolkit/tests]
server/src/__tests__/plugin-store-consistency.test.ts:975—expect(elapsedMs).toBeGreaterThanOrEqual(10_000)/toBeLessThan(35_000)(:976) hard-codes bounds derived fromSDK_STORE_CONSISTENCY_MIN_STABLE_MISMATCH_MSand the delay ladder (500+1500+3500+7500+15500, so the recheck returns at ~13s). Deriving them from the exported constants would keep the assertion honest if the ladder is retuned — and it is worth noting this one case adds ~13s of wall clock to the suite. - [pr-review-toolkit/tests]
server/src/__tests__/plugin-store-consistency.test.ts:351—writeLockfileOmittingreads the file back to assert on a JSON literal it wrote three lines earlier. The fixture guard is a good instinct, but it can only fail if someone edits that same literal; asserting on the object (or a comment) avoids the I/O round-trip. - [native-codex]
server/src/__tests__/plugin-store-consistency.test.ts:904—ISOLATED_SDK_PLUGIN_PACKAGES[0](also:925) ties the new activation test to list ordering. The sweep test already covers every entry, so naming the intended package explicitly would say what the test means and survive a reordering of the list.
Strengths
- The asymmetry between the two single-sided absences is documented with the measurement that justifies it (live PVC, 2026-09-04, all three isolated dirs), plus a pointer to the unmerged working tree that got it wrong. That is the rare comment that will actually stop the next person from "tightening" this.
- Recording that
require.resolveandimport.meta.resolvewere both tried and rejected against the real layout — with the reason (ESM-onlyexports, norequirecondition) — pre-empts the obvious stronger gate and explains why it cannot be used. Negative results are usually the part that gets lost. problemis widened as a discriminated union member rather than folded into an existing value, and there is a test asserting all three values are distinct, so a future collapse fails loudly.- The discriminating test ("still rejects that same tree once the installed SDK is removed") is the right control: identical fixture, one variable, opposite verdict. It shows the acceptance is not blanket permissiveness.
paperclip-plugin-hindsightis covered explicitly with an assertion that it is isolated-but-not-bundled — verified accurate against both lists at this head. A fixture built only from bundled rows would have missed that row.
Recommended Action
- No Critical issues — nothing blocks on correctness of the congruent path.
- Scope the
not_installedverdict to server-owned install dirs (Important #1) before merge; it is the one change that can break a working dev checkout. - Give
not_installeda re-probe-based un-latch (Important #2) so a slow boot install self-heals rather than requiring an operator. - Take the Suggestions opportunistically.
Thinking Path
Linked Issues or Issue Description
No duplicate or related GitHub PRs found. Searched open + closed PRs for
not_installed,checkSharedDependencyConsistency,plugin-sdk,31857,28656,consistency:only unrelated hits (#1641 SDK idempotent comments, #1585 CI review guard).
What Changed
checkSharedDependencyConsistencynow fails closed when the SDK is neither recorded inpackage-lock.jsonnor installed undernode_modules, reported as a new, distinctproblem: "not_installed".(lock absent)/(installed ok)— the normal peer-dependency-only shape; measured on the live PVC 2026-09-04 for@lucitra/paperclip-plugin-secrets,@lucitra/paperclip-plugin-chatand@penstock/paperclip-plugin.(lock ok)/(installed absent)— already owned by the boot-timeSDK_INSTALL_RACE_RETRY_DELAYS_MSretry.SDK_NOT_INSTALLED_ERROR_MARKERfor the refusal message, intentionally not reusingTORN_STORE_ERROR_MARKER:reconcileLegacyIsolatedInstallsAtStartupun-latches rows carrying that marker, which would revive an empty tree into the same failure on the next boot.BLO-31857: the healthy isolated-tree shape must stay congruent (outage guard)covering the peer-dependency-only shape (lucitra.plugin-secrets), the operator-installed / non-bundled row (paperclip-plugin-hindsight, asserted absent fromBUNDLED_PLUGIN_PACKAGES), a sweep over everyISOLATED_SDK_PLUGIN_PACKAGESentry, the recheck fast path, and transient-boot settling.plugin-database.test.ts:refreshes persisted manifests from disk before activationwas the vacuous fixture this change exposes — it droveloadSingleagainst the real~/.paperclip/plugins. Given an isolated store with the SDK installed, so it tests manifest refresh rather than ambient disk state.Verification
cd server && npx vitest run src/__tests__/plugin-store-consistency.test.ts→ 25 passed.Full activation-path sweep (every test file that calls
loadSingle/loadAllor instantiatespluginLoader) → 74 passed / 7 files:plus
src/__tests__/plugin-install-autobuild.test.ts→ 9 passed.npx tsc -p server/tsconfig.json --noEmit→ clean (one pre-existing unrelatedpackages/adapter-utilsacpx/runtimeerror, untouched by this PR).The fixture discriminates. A temporary harness importing only symbols that exist on
master head was run against master's
plugin-loader.tsand then against this branch's.(lock absent)/(installed ok)passes both times;(absent)/(absent)fails on master andpasses here:
(lock absent)/(installed ok)— identical before and after:{ "packageName": "@paperclipai/plugin-sdk", "lockfileVersion": null, "installedVersion": "2026.817.0", "lockfileState": "missing", "installedState": "ok", "consistent": true, "problem": null, "diagnostic": null }(absent)/(absent)on master595e2beb— the vacuous pass:{ "packageName": "@paperclipai/plugin-sdk", "lockfileVersion": null, "installedVersion": null, "lockfileState": "missing", "installedState": "missing", "consistent": true, "problem": null, "diagnostic": null }(absent)/(absent)on this branch:{ "packageName": "@paperclipai/plugin-sdk", "lockfileVersion": null, "installedVersion": null, "lockfileState": "missing", "installedState": "missing", "consistent": false, "problem": "not_installed", "diagnostic": null }Independent evidence that the vacuous pass was real, not theoretical: with the guard
tightened,
plugin-database.test.tsfailed because it had been activating a plugin withno SDK anywhere on disk. That is the defect, caught by the change.
Production baseline for the post-deploy check:
paperclip_plugin_erroris0for all 11plugins, including
lucitra.plugin-secrets,penstock.paperclip-plugin,paperclip-plugin-hindsightandpaperclip-chat.Risks
Low, but the risk is concentrated in one place and worth stating precisely.
(absent)/(absent)fails activation for any plugin whose install dir has no SDK. That cannot be a healthy production shape: an isolated dir's parent chain deliberately excludes the shared store (the whole point of BLO-20961), so a plugin there with no local SDK could not resolve it and would already be erroring — and all four isolated plugins are atpaperclip_plugin_error = 0. For shared-store plugins the SDK sits in the store the guard reads.(absent)/(absent)is genuinely transient while the concurrent bootnpm installruns. It is safe because every inconsistent result routes throughcheckSharedDependencyConsistencyAfterRecheck, which returns as soon as the SDK lands (first recheck at 500ms) and only fails closed on a mismatch stable past 10s. A test covers exactly this. The cost of being wrong is bounded and visible: a plugin markederrorwith an actionable message, not a silent 60s hang.stated this run — this run hadk8s-roonly, with no exec verb. The argument above is mechanism-based plus the error-metric baseline, and the deploy check below is what would catch it.not_installedis not un-latched byreconcileLegacyIsolatedInstallsAtStartup, by design: relocation does not populate an empty tree, so reviving it would just re-fail. It needs a real install.paperclip_plugin_errormust stay0forlucitra.plugin-secrets,lucitra.plugin-chat/paperclip-chat,penstock.paperclip-pluginandpaperclip-plugin-hindsightfor 24h.Model Used
claude-opus-4-5, 1M context) via Claude Code, extended thinking, with tool use (filesystem/bash/git/gh, Kubernetes read-only, Prometheus).Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template