feat(store): refuse to install a plugin that needs a newer core (re-target of #429) - #431
Conversation
`ledmatrix_min_version` was decoration. The loader logged an advisory warning and continued; the store never compared the core version at all, so a routine "update" delivered a plugin that could not run. That is the gap phase B6 (the sports-unification sunset) cannot be done over: deleting a plugin's bundled fallback while nothing enforces the floor hands un-updated users a scoreboard that raises ModuleNotFoundError at load and is reported only as one line in the journal. The gate lives in install_plugin, after the manifest is on disk and before dependencies are installed. That is the earliest knowable point -- the registry carries no compatibility field, so the floor is not visible until the files are down -- and it is also the chokepoint: _reinstall_with_rollback calls install_plugin, so a refused *update* restores the version the user already had, for free. Floor resolution and the comparison move to src/plugin_system/compatibility.py, shared with the loader so the two cannot drift. Both read all four spellings published manifests use, including the deprecated `ledmatrix_min`. Refusal requires evidence. An undeclared floor, an unparseable version on either side, or a core below TRUSTWORTHY_FLOOR (2.0.0) all allow the install. That last one is deliberate and load-bearing: the v3.1.0 release reports __version__ = "1.0.0" while nearly every published manifest floors at 2.0.0, so a strict gate would lock those users out of the plugin store entirely -- much worse than the problem being solved. They stay unprotected until they update the core, which is also what fixes their version string. Verified: 782 core unit tests pass, including 25 new ones and the existing loader-warning suite unchanged (the refactor is behavior-preserving). The install tests drive the real install_plugin path with the download stubbed -- the allow and refuse cases differ only in the declared floor, so the refusal is demonstrably the gate and not an earlier bail-out. Follow-ups, deliberately not in this PR: surfacing the reason in the store UI rather than only the log, and publishing the floor in plugins.json so the store can refuse before downloading. Phase B4 in docs/SPORTS_UNIFICATION.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
Found while validating the compatibility gate. `_install_plugin_impl` deletes the existing plugin directory *before* downloading, so any failure after that point leaves the user with nothing. `_reinstall_with_rollback` protects the update path exactly this way; a direct `install_plugin` had no equivalent. The gate made this reachable in a new way: a plugin whose declared floor exceeds the running core is now refused *after* the old copy is already gone. Floors are hand-written and can be over-declared, so the refusal could remove a plugin that had been working fine on that core. install_plugin is now a thin wrapper that renames any existing install aside, delegates to _install_plugin_impl, and restores it on failure -- including when the implementation raises, which is re-raised after the restore. It is a pass-through when nothing is installed and when called from _reinstall_with_rollback, which has already moved the old copy aside; a test pins that so the two mechanisms cannot start nesting. The aside name embeds '.standalone-backup-' because plugin_manager._scan_directory_for_plugins keys on exactly that substring to skip backups. A different name would have made the backup discoverable as a duplicate plugin; a test pins that too. 789 core unit tests pass, including 7 new ones. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
Second bug found while validating the previous commit on hardware. install_plugin's new set-aside/restore had no lock. The web UI runs Flask threaded, so a double-clicked Install button gives two threads the same plugin_id; interleaved, one thread's restore deletes the other's freshly installed copy. _reinstall_with_rollback already guards exactly this with a per-plugin lock, and install_plugin needs the same one. Taking that lock naively deadlocks. _reinstall_with_rollback holds it across its call to install_plugin, and threading.Lock is not reentrant -- so the request thread hangs forever on the standard monorepo update path (update_plugin -> _reinstall_with_rollback -> install_plugin), which is to say on every plugin update. Verified by reverting to a plain Lock: the regression test times out after 10s instead of passing. The per-plugin locks are now RLocks, and install_plugin holds one for its whole set-aside/install/restore sequence. Verified on devpi (Pi, Python 3.13.5, real registry and network): - update_plugin on an up-to-date plugin: True in 5.4s - update_plugin forced through the full reinstall-with-rollback path: True in 13.1s, correct version restored, old copy replaced, no backup directories left behind - install -> reinstall-over-existing -> failed-reinstall-restores: all pass against real downloads - 22 plugins load, no tracebacks, web API and UI 200, steady-state journal 50 lines/min 791 core unit tests pass, including 2 new concurrency tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
|
Warning Review limit reached
Next review available in: 36 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughThe PR adds shared plugin compatibility checks, enforces them during official installation, and protects existing installations with hidden backups, restoration handling, reentrant locks, and concurrency regression tests. ChangesPlugin installation behavior
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant PluginStoreManager
participant compatibility.check
participant PluginDirectory
participant DependencyInstaller
PluginStoreManager->>PluginDirectory: Move existing installation to hidden backup
PluginStoreManager->>compatibility.check: Validate manifest against core version
compatibility.check-->>PluginStoreManager: Return compatibility verdict
alt Compatible
PluginStoreManager->>DependencyInstaller: Install plugin dependencies
PluginStoreManager->>PluginDirectory: Remove backup after success
else Incompatible or failed
PluginStoreManager->>PluginDirectory: Delete partial installation
PluginStoreManager->>PluginDirectory: Restore hidden backup
end
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 38 |
| Duplication | 2 |
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
These were split out of #428/#429 because the token pushing them lacked the `workflow` scope. Folding them in here rather than opening a stacked PR -- #429 was merged into its stacked base after that base had already been squash-merged, so its content never reached main, and one such near-miss is enough. All three enrolled suites exist on this branch: test_version_consistency.py came with #428 and is on main; the other two arrive with the commits above. Enrolling them in a separate PR would have either raced with this one on test.yml or briefly pointed CI at files main did not have. - test.yml: enroll test_version_consistency, test_plugin_compatibility_gate and test_install_preserves_existing in the core unit job. Until now these 32 tests existed but nothing ran them automatically. - release-version-check.yml: run scripts/check_release_version.py on pushed v* tags and published releases, plus workflow_dispatch so a tag can be checked *before* it is created. No dependencies -- it reads src/__init__.py and CHANGELOG.md only. Verified: both workflow files parse, and the release check still passes for v3.2.0 against this tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
There was a problem hiding this comment.
🧹 Nitpick comments (3)
src/plugin_system/plugin_loader.py (1)
685-698: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConfirm
_parse_semverhas no remaining callers, then remove it.
_warn_if_incompatiblenow usescompatibility.parse_semver(Line 712, 719) instead ofself._parse_semver. This static method appears to duplicatecompatibility.parse_semverexactly. Keeping both risks the two copies drifting apart if one is fixed (e.g., the dead-except issue flagged incompatibility.py) without the other being updated.#!/bin/bash # Description: Check whether PluginLoader._parse_semver still has any callers. rg -n '_parse_semver' src/plugin_system/plugin_loader.py rg -n '\._parse_semver\(' src -g '*.py'🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/plugin_system/plugin_loader.py` around lines 685 - 698, Confirm that no callers remain for PluginLoader._parse_semver, then remove this static method and its associated dead code. Preserve _warn_if_incompatible’s use of compatibility.parse_semver as the single semver parsing implementation.test/test_plugin_compatibility_gate.py (1)
204-232: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winExercise the real
_warn_if_incompatible, not a re-derivation of its logic.This test recomputes the loader's warn/no-warn decision inline (Lines 220-231) instead of invoking
PluginLoader._warn_if_incompatibleon an instance. The only real touch ofPluginLoaderishasattr(PluginLoader, "_warn_if_incompatible")(Line 232), which only confirms the method exists. If the loader's implementation ever diverges from this reimplementation (e.g., a future change to the debug-log branch), this test keeps passing because it checks its own copy of the logic, not the loader's.Instantiate
PluginLoaderwith a mocked logger, monkeypatchsrc.__version__, call the real method, and assert onlogger.warning.calledto genuinely pin the cross-module agreement this test class is named for.def test_same_verdict(self, manifest, core, expected, monkeypatch): import src from src.plugin_system.plugin_loader import PluginLoader store_ok, _ = compatibility.check(manifest, core) assert store_ok is expected monkeypatch.setattr(src, "__version__", core) loader = PluginLoader(logger=MagicMock()) loader._warn_if_incompatible("some-plugin", manifest) assert loader.logger.warning.called is (not expected)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/test_plugin_compatibility_gate.py` around lines 204 - 232, Update TestLoaderAndStoreAgree.test_same_verdict to exercise PluginLoader._warn_if_incompatible directly instead of recomputing its decision with compatibility.parse_semver and compatibility.declared_min_version. Add monkeypatch, set src.__version__ to core, instantiate PluginLoader with a mocked logger, invoke _warn_if_incompatible with the manifest, and assert logger.warning.called matches not expected.test/test_install_preserves_existing.py (1)
45-129: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winAdd coverage for the "no rollback net" fallback branches.
TestFailedInstallPreservesPreviouscovers the happy path of setting a backup aside, but two fallback branches ininstall_plugin(store_manager.py:1203-1267) have no test:
backup_path.exists() and not self._safe_remove_directory(backup_path)→ falls back to installing without a rollback net.plugin_path.rename(backup_path)raisingOSError→ falls back to installing without a rollback net.The source comments mark these as deliberate degrade-gracefully behavior ("Better to attempt the install than to refuse outright"). A regression that turns either fallback into an outright refusal, or that raises instead of degrading, would pass this suite unnoticed.
Add two tests that mock
_safe_remove_directoryto returnFalseand mockPath.rename(or the plugin path's.rename) to raiseOSError, then assert_install_plugin_implis still invoked.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/test_install_preserves_existing.py` around lines 45 - 129, Add two tests to TestFailedInstallPreservesPrevious covering install_plugin’s no-rollback fallback paths: one with an existing backup where _safe_remove_directory returns False, and one where the existing plugin path’s rename raises OSError. In both cases, mock _install_plugin_impl and assert it is still invoked, confirming installation degrades gracefully instead of refusing or propagating the setup error.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@src/plugin_system/plugin_loader.py`:
- Around line 685-698: Confirm that no callers remain for
PluginLoader._parse_semver, then remove this static method and its associated
dead code. Preserve _warn_if_incompatible’s use of compatibility.parse_semver as
the single semver parsing implementation.
In `@test/test_install_preserves_existing.py`:
- Around line 45-129: Add two tests to TestFailedInstallPreservesPrevious
covering install_plugin’s no-rollback fallback paths: one with an existing
backup where _safe_remove_directory returns False, and one where the existing
plugin path’s rename raises OSError. In both cases, mock _install_plugin_impl
and assert it is still invoked, confirming installation degrades gracefully
instead of refusing or propagating the setup error.
In `@test/test_plugin_compatibility_gate.py`:
- Around line 204-232: Update TestLoaderAndStoreAgree.test_same_verdict to
exercise PluginLoader._warn_if_incompatible directly instead of recomputing its
decision with compatibility.parse_semver and compatibility.declared_min_version.
Add monkeypatch, set src.__version__ to core, instantiate PluginLoader with a
mocked logger, invoke _warn_if_incompatible with the manifest, and assert
logger.warning.called matches not expected.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: c5b00e9c-07c2-426e-9a37-48cd00689dfb
📒 Files selected for processing (5)
src/plugin_system/compatibility.pysrc/plugin_system/plugin_loader.pysrc/plugin_system/store_manager.pytest/test_install_preserves_existing.pytest/test_plugin_compatibility_gate.py
The 3.2.0 section described the unified sports library but none of the install-path work that landed in ChuckBuilds#428 and ChuckBuilds#431 -- which matters more than a normal changelog omission, because the sunset rule keys on this section to tell plugin authors what a given floor buys them. The headline addition: 3.2.0 is the first release that *enforces* ledmatrix_min_version. Before it the floor was advisory, so a plugin could declare one and still be delivered to a core that could not run it. That is the property B6 waits on, and it is now stated where a plugin author will look for it -- along with the caveat that a core reporting below 2.0.0 is treated as unknown rather than old and is never blocked. Also records compatibility.py (and that it does not yet read compatible_versions), check_release_version.py and its workflow, the install-preservation fix, the reentrant-lock deadlock fix, and the web_interface version re-export. No version bump: 3.2.0 is unreleased, so this describes the release being cut rather than a new one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
A line wrapped onto "#433), the newest manifest entry ...", which markdownlint reads as a malformed ATX heading (MD018). Reflowed so the line starts with "(#431, #433)" instead. Not the suggested fix: adding a space after the hash would have turned the PR reference into "# 433". The B5 safety claim raised alongside this was already corrected in ac44b5a. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
The heading read "B5 — adoption is safe by construction", which this same document disproves two sections later: four of the eight adopted plugins shipped with scroll mode broken on a 3.2.0 core and were repaired in plugins #251. The body was already careful -- it says fallback compatibility is what is guaranteed, and that correctness on a core which *does* ship the module needs object-level and scroll-mode validation. The heading was not, and a heading is what a reader scanning the plan actually takes away. Retitled to name both halves, with a sentence up front saying why the unqualified claim is false and pointing at the retrospective that shows it. The phase intro said "one of them is safe by construction and the other is not"; that now says what it actually means -- one cannot break a user on an old core, the other can. The second review point, MD018 on the ATX heading at line 409, does not reproduce: that line now begins "(#431, #433)" rather than "#433)", so there is no bare-hash heading. `grep -cE '^#+[^ #]'` returns 0 for the whole file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
Sideloading a plugin from a URL validated required manifest fields, warned on version-field and schema problems, and then installed whatever it had found -- never once comparing the core version. With this, all three routes in refuse on the same rule: install_plugin (#431/#433), the git-pull update path (the commit before this one), and now sideloading. The argument for gating it is not the same as for the other two, and worth stating. An automatic store update is something that happens TO a user; a sideload is something they chose. But the floor is not advice about intent -- it is a statement that the plugin cannot run on this core. Letting it through produces exactly the silent PluginState.ERROR at load that the store gate exists to prevent, and someone who pasted a URL is no better placed to diagnose that than someone who pressed Update. Checked after the download, like the others, because the manifest is the only place the floor is written. Placed before the move into plugins/, so the existing `finally` removes the temp tree and nothing half-installed survives a refusal. The reason string is returned to the caller, so the web UI shows the same actionable message the store path already gives. TestSideloadGate pins the three cases the other routes pin: refuses a floor above this core and leaves nothing behind, still allows a compatible plugin (the guard against a gate that refuses everything and passes the first test), and does not block a 2.0.0 floor on a core reporting an untrustworthy version -- the v3.1.0-reports-1.0.0 population, who must not be locked out. TestInstallGate's docstring said this route was still ungated. It no longer is, so it now points at TestSideloadGate rather than describing a gap. Verified the tests bite: removing the check fails the refusal case. Full suite 3723 passed, 6 skipped. Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This PR was opened to say B6 was deliberately held. It has since run, so the document said the opposite of the truth -- which is the exact failure it was written to fix: "a plan that misreports which phase it is in is worse than no plan". Merges current main first (53 commits), which brings in #508's corrections to this same file, then replaces the hold with what happened. **Why the hold lifted is worth recording, because the stated gate was never met.** It asked for evidence of 3.2.0 uptake, and that evidence could not arrive: the core updates by `git pull --rebase`, so release-asset counts cannot measure it, and no store-side telemetry exists. What changed is that the risk the gate protected against was closed directly -- the store now refuses a plugin whose floor exceeds the running core on all three routes in: install_plugin (#431/#433), update_plugin's git branch (#508), and install_from_url (#510). A pre-3.2.0 user cannot receive a sunset plugin at all, so they keep the version they run. Refusal replaced the bundled copy, which is what the copy stood in for. Records what shipped (eight plugins, ~5,800 lines, plugins #346/#349/#350/#351) and the two findings worth carrying to the next module: baseball's fallback was the only one holding orchestration logic the core lacked, and two tests had been leaning on the guard -- soccer's stubbed `src` in a way that shadowed the core, so it had been exercising the frozen copy rather than the shipping class since B5. The remaining-work list is replaced too. Its first item was "nothing on the critical path, B6 is waiting on calendar time", which is no longer true. What remains: hardware soaks (with a note to check the rig's display_mode first, or a board in switch mode tells you nothing about the scroll code), cutting 3.3.0 -- not required by B6, whose floors are 3.2.0, but calendar 1.2.3 floors at 3.3.0 and is un-installable until it exists -- and reconsidering the modules held back during the sunset. Keeps the pre-B6 sections as history. The reasoning still applies to the next module; it is just no longer in force for this one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9
…511) #510 shows as merged, but into fix/gate-git-pull-updates -- #508's branch -- rather than main. #508 reached main first, so the sideload gate was left behind on a branch. Same failure as plugins #350/#351, which merged into each other's bases; worth knowing the pattern, because GitHub reports these as MERGED and `gh pr list` shows nothing outstanding. main today has two of the three routes gated: install_plugin (#431/#433) and update_plugin's git branch (#508). install_from_url validates required manifest fields and then installs whatever it found, never comparing the core version. Cherry-picked unchanged from the orphaned branch -- it applies to main with no conflict. TestSideloadGate pins the three cases the other routes pin: refuses a floor above this core leaving nothing behind, still allows a compatible plugin (the guard against a gate that refuses everything), and does not block a 2.0.0 floor on a core reporting an untrustworthy version. Full suite 3725 passed, 6 skipped. Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(sports): record where B6 stands, and why it is waiting
The phase table had B4 as "next" and B5 as "after B4" while both had shipped,
and described B6 as blocked on B4's gate — which is now merged and released. A
plan that misreports which phase it is in is worse than no plan: the next
person reads it and repeats finished work.
Corrected, and three things that were only ever decided in conversation are now
written down:
* **B6 is deliberately held.** 3.2.0 published 2026-08-03; 3.1.0 ran nine
months before it. B6's premise is that cores without the module are gone,
and there is no release-asset count or install telemetry to show that.
Running it now strands users on their current plugin versions. The gate
that makes it safe is already built and tested — it is the calendar that is
missing, and no amount of further code changes that.
* **Stop adopting further shared modules** (data_sources, game_renderer,
base_odds_manager) until B6 closes. Each adoption adds a copy to keep in
step against a payoff contingent on B6.
* **A B5 retrospective**, because "the adoption went fine" is not what
happened: four of eight shipped with scroll mode broken on a 3.2.0 core.
The bundled fallback did not protect against it — the break was on the
modern path — which is an argument for the sunset, not against it. Records
the ledger too: net negative on disk until B6 runs.
Also replaces the "what's next" list, whose first five items were all done,
with what actually remains.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
* fix: apply CodeRabbit auto-fixes
Fixed 1 file(s) based on 2 unresolved review comments.
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
* docs(sports): stop a wrapped PR reference reading as a heading
A line wrapped onto "#433), the newest manifest entry ...", which
markdownlint reads as a malformed ATX heading (MD018). Reflowed so the
line starts with "(#431, #433)" instead.
Not the suggested fix: adding a space after the hash would have turned
the PR reference into "# 433". The B5 safety claim raised alongside this
was already corrected in ac44b5a.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
* docs(sports): scope the B5 safety claim to the fallback
The heading read "B5 — adoption is safe by construction", which this same
document disproves two sections later: four of the eight adopted plugins
shipped with scroll mode broken on a 3.2.0 core and were repaired in
plugins #251.
The body was already careful -- it says fallback compatibility is what is
guaranteed, and that correctness on a core which *does* ship the module
needs object-level and scroll-mode validation. The heading was not, and a
heading is what a reader scanning the plan actually takes away.
Retitled to name both halves, with a sentence up front saying why the
unqualified claim is false and pointing at the retrospective that shows
it. The phase intro said "one of them is safe by construction and the
other is not"; that now says what it actually means -- one cannot break a
user on an old core, the other can.
The second review point, MD018 on the ATX heading at line 409, does not
reproduce: that line now begins "(#431, #433)" rather than "#433)", so
there is no bare-hash heading. `grep -cE '^#+[^ #]'` returns 0 for the
whole file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
* docs(sports): re-check the hold, and close two items that are already done
The remaining-work list had two entries that finished without the doc noticing,
which is the failure mode this file exists to prevent.
- The stale plugin-test tranche is gone. run_plugin_tests.py --all now reports
174 passed, 2 skipped, 0 failed across the whole fleet. Recorded how to
re-check it too: these are standalone scripts, not a pytest suite, and one
calls sys.exit(1) at import, so pointing pytest at a plugin directory
collapses into an INTERNALERROR that looks nothing like the real state.
- CLAUDE.md already says eight panel sizes.
That leaves the hardware soaks as the only open item needing work rather than
calendar time.
B6's prerequisite is now built -- core test/test_sports_sunset_matrix.py
(#505) -- so the phase table and the regression-test section say so, and the
two modelling traps it had to work through are recorded for whoever touches it
next: the copy-removed shape must be an unguarded import or the failure names
scroll_display_legacy instead of the core module, and only the leaf module may
be hidden because a pre-3.2.0 core still ships src/common/.
The hold itself is re-checked and unchanged: v3.2.0 is still latest,
__version__ is still 3.2.0, no 3.3.0, 23 days rather than the few months the
gate asks for. Also worth stating plainly -- the core updates by git pull, not
by downloading a release, so release-asset counts would not measure uptake even
if we had them. Whatever unblocks this has to come from the store side.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
Re-opens #429 against
main. #429 was merged, but into its stacked basepush/version-reporting— which had already been squash-merged as #428. So the merge landed on an orphaned branch and none of this reachedmain. Verified:src/plugin_system/compatibility.pyis absent frommain, andstore_manager.pythere has neither_install_plugin_implnorthreading.RLock().Nothing was lost. These are the same three commits, cherry-picked onto current
main(which now also carries #428 and #430). No conflicts — #430 touched installer scripts, not the plugin system.The review-thread fixes from #427 are already on
mainand are not repeated here.Why
ledmatrix_min_versionwas decoration. The loader logged an advisory warning and continued; the store never compared the core version at all, so a routine "update" delivered a plugin that could not run. This is the change B6 — the sports-unification sunset — depends on.The gate
Lives in
install_plugin, after the manifest is on disk and before dependencies install — the earliest knowable point, since the registry carries no compatibility field. It is also the chokepoint:_reinstall_with_rollbackcallsinstall_plugin, so a refused update restores the version the user already had.Floor resolution moves to
src/plugin_system/compatibility.py, shared with the loader so the two cannot drift.Refusal requires evidence. An undeclared floor, an unparseable version on either side, or a core below
TRUSTWORTHY_FLOOR(2.0.0) all allow the install. That last one is load-bearing: the v3.1.0 release reports__version__ = "1.0.0"while nearly every manifest floors at2.0.0, so a strict gate would lock those users out of the store entirely. A regression test pins it.The gate is inert today — all 42 manifests declare exactly
2.0.0. It arms when B5 starts declaring3.2.0floors.Known gap, recorded in #427: the gate reads
versions[].ledmatrix_min_version, notcompatible_versions— which is the schema-required field and supports upper bounds. Harmless now (no manifest uses one), must close before B6.Two bugs found while validating on hardware
A failed install destroyed the plugin it replaced.
_install_plugin_impldeletes the existing directory before downloading; the update path was protected by_reinstall_with_rollback, a direct install was not — and the gate added a new way to fail late.install_pluginis now a wrapper that sets the old copy aside and restores on failure, including on exceptions. The aside name embeds.standalone-backup-becauseplugin_manager._scan_directory_for_plugins:177keys on that substring.The fix for Stocks #1 would have deadlocked every plugin update. The wrapper needs the per-plugin lock, but
_reinstall_with_rollbackholds it across its call toinstall_pluginandthreading.Lockis not reentrant — soupdate_plugin → _reinstall_with_rollback → install_pluginwould hang the request thread. Confirmed by reverting to a plainLock: the test times out at 10s. NowRLock.Verified
792 core unit tests pass on this branch against current
main.On devpi (Pi, Python 3.13.5, real registry and network): fresh install, reinstall-over-existing, failed-reinstall-restores, and a real
update_pluginthrough the full rollback path in 13.1s. 22 plugins load, web API and UI 200.Still outstanding
CI does not run the three new test files — the
test.ymlenrollment needs theworkflowscope, which the pushing token still lacks. That follow-up is unchanged.🤖 Generated with Claude Code
https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
Summary by CodeRabbit
New Features
Bug Fixes