Record the venv own requirement digests, not the installers - #9263
Conversation
|
Confirmed against main, where install_manifest.py records digests from the installer's requirements tree while verify_install reads the installed package's copy, so any upstream requirement change makes a fresh desktop install repair itself on first launch. Will get this reviewed. |
Every fresh desktop install on Linux and macOS came up ManagedStale and
repaired itself before it would run. From the app's own log:
05:44:22 Managed preflight: install probe result Stale { reason: "studio_install_requirements_changed" }
05:44:22 desktop_preflight completed disposition=ManagedStale
05:44:22 start_managed_repair command called
05:44:24 studio install incomplete -- forcing dependency pass to repair...
05:44:32 Managed preflight: install probe result Ready
The manifest recorded digests from REQ_ROOT, which is the requirements
directory next to whichever install_python_stack.py ran. A desktop bundle
carries its own copy. verify_install reads the INSTALLED package's copy,
because at verify time install_manifest.py is imported out of the venv.
Two different trees, compared to each other.
They agree until a tracked requirement file changes upstream, and then
every install performed by that bundle is stale for ever. v0.1.800-beta
was cut 2026-08-14 and installs unsloth 2026.8.18; #9148 pinned openai in
extras.txt in between. Windows was unaffected only by luck of layout.
The digests now describe the tree the verifier will read. A source or
editable install has no copy under site-packages and still uses the root
it was given, which is what keeps an edited studio.txt invalidating the
manifest on the --local path.
Also: the CI gate reported "saw: ManagedStale" and nothing else on all
four platforms. The reason is logged on its own line, and tail -60 scrolls
it away the moment the backend starts logging, so the one fact that
explained this had to be recovered from an uploaded artifact by hand. All
three log dumps now grep for it.
for more information, see https://pre-commit.ci
1e7cb54 to
2bf446d
Compare
mahiatlinux
left a comment
There was a problem hiding this comment.
The bundle carries no requirements tree, so the root cause recorded in the code, the test and the workflow is not what happened
studio/install_manifest.py:88, tests/studio/install/test_install_manifest.py:297, .github/workflows/desktop-app-clean-machine-ci.yml:282 and :533 all now state that "a desktop bundle carries its own studio/install_python_stack.py, and its requirements are whatever they were when that bundle was cut".
It does not. studio/src-tauri/tauri.linux.conf.json:3-5 and tauri.macos.conf.json:3-5 declare exactly one bundled resource each, "../../install.sh". There is no studio/ tree in the bundle, no install_python_stack.py and no backend/requirements/. Had REQ_ROOT come from the bundle, requirement_digests() would have returned {} and every install would have been stale from the first bundle, not from 2026-08-19.
What actually splits the two trees is install.sh:5353-5357:
SETUP_SH=$("$VENV_DIR/bin/python" -c "
import importlib.resources
print(importlib.resources.files('studio') / 'setup.sh')
" 2>/dev/null || echo "")python -c puts '' on sys.path[0] (verified on 3.9 and 3.13), so the cwd wins over site-packages. Against an installed managed venv:
$ cd <repo checkout> && "$VENV/bin/python" -c "import importlib.resources; print(importlib.resources.files('studio')/'setup.sh')"
<repo checkout>/studio/setup.sh
$ cd /tmp && "$VENV/bin/python" -c "import importlib.resources; print(importlib.resources.files('studio')/'setup.sh')"
<venv>/lib/python3.13/site-packages/studio/setup.sh
The workflow runs bash "$SH" --tauri (:215, :474) with no working-directory, so cwd is $GITHUB_WORKSPACE, and actions/checkout (:104, :352) has just put studio/__init__.py there. SETUP_SH, install_python_stack.py and REQ_ROOT therefore bind to the checkout at main HEAD while the venv holds unsloth 2026.8.18 from PyPI. That is the two-tree split, and it is a property of the CI harness, not of the bundle.
The timeline in the same comments is impossible. unsloth 2026.8.18 was uploaded 2026-08-14T15:00:36Z and v0.1.800-beta published 2026-08-14T14:18:49Z, 42 minutes apart. #9148 merged 2026-08-18T09:23:46Z, four days after both. Nothing changed "in between" the bundle and the backend it installs; #9148 changed main, which is the tree the job's cwd shadows in.
Two further claims fall with it:
- "Every fresh desktop install was coming up stale ... on every new user's first launch."
studio/src-tauri/src/install.rs:558spawns the installer withcurrent_dir(install_working_dir(home)), which is$HOME/.unsloth(process.rs:1299).~/.unsloth/studiohas no__init__.py, so it is a namespace portion only and the regular package in site-packages wins the import. A real first launch resolvesstudioto the venv and the two roots are the same directory. The 10-second repair needs a cwd holding a realstudiopackage: the CI job, or a developer running./install.shfrom a clone. - "Windows was unaffected, but only by luck of layout, and it would have followed on the next release."
install.ps1:5414and:5462never locatesetup.ps1at all; they callunsloth studio setupthrough the venv's CLI, andunsloth_cli/commands/studio.py:685-712resolves it from_PACKAGE_ROOTunder the docstring "No CWD dependency -- works from any directory." Windows cannot takeREQ_ROOTfrom a shadowing cwd, on this release or the next.
That also breaks the job's stated premise. .github/workflows/desktop-app-clean-machine-ci.yml:4-10 exists to run "the SHIPPED desktop app" and "its bundled Contents/Resources/install.sh, which no CI job exercised", and the shadow silently substitutes branch source for the released wheel's Python side. The leg is green on this branch because the checkout it shadows in now carries the fix, not because anything shipped changed.
The defect worth fixing is the lookup, which already has a cwd-free counterpart on the Windows side:
SETUP_SH=$("$VENV_DIR/bin/python" -c "
import sys; sys.path.pop(0)
import importlib.resources
print(importlib.resources.files('studio') / 'setup.sh')
" 2>/dev/null || echo "")verify_install still resolves its requirements root the old way, so the fix moves the mismatch onto setup.sh and setup.ps1
studio/install_manifest.py:173 now writes the venv's digests, but :372 still reads req_root or requirements_root(), and requirements_root() (:70) resolves next to whichever install_manifest.py was imported.
Two of the three readers named in this module's own header import it from the installer tree, not the venv. studio/setup.sh:1830-1838 and studio/setup.ps1:4770-4778 both do sys.path.insert(0, "$SCRIPT_DIR") / $PSScriptRoot and then call install_manifest.verify_install() with no arguments, so the manifest is read out of the venv while the digests are computed from the setup script's own tree.
With the installer tree and the venv's tree differing only in extras.txt, the #9148 shape, and write_manifest invoked exactly as install_python_stack.py:4740 invokes it, the two readers swap places:
===== install_manifest.py @ merge base =====
setup.sh / setup.ps1 fast path : ok=True reason=None
verify-install / desktop preflight: ok=False reason=studio_install_requirements_changed
===== install_manifest.py @ head =====
setup.sh / setup.ps1 fast path : ok=False reason=studio_install_requirements_changed
verify-install / desktop preflight: ok=True reason=None
A CLI outside the managed venv is explicitly supported: unsloth_cli/_studio_deps.py:117-127 _managed_root, and _find_setup_script step 2 at unsloth_cli/commands/studio.py:708-714. For such a CLI _find_setup_script(None) returns _PACKAGE_ROOT/studio/setup.sh, a tree that is not the managed venv's, while setup.sh targets $STUDIO_HOME/unsloth_studio. A bare ./studio/setup.sh from a checkout, the invocation documented at studio/install_python_stack.py:3417, does the same. In both cases every run now prints "studio install incomplete -- forcing dependency pass to repair..." and does the full dependency pass, on Linux, macOS and Windows, and the next run repeats it because the pass rewrites the manifest from the venv again.
Both sides have to resolve alike. In verify_install:
reqs = req_root or installed_requirements_root(root) or requirements_root()installed_requirements_root accepts a directory holding none of the tracked files, and the empty digest map it produces reports a gutted venv as complete
studio/install_manifest.py:98-101 returns on reqs.is_dir() alone. A site-packages/studio/backend/requirements/ that exists without its .txt files is accepted, requirement_digests then returns {}, and write_manifest records that with no complaint.
Both failure modes are live. missing_requirements returns [] when it cannot read studio.txt (:328-330), so a reader pointed at that same directory gets deps_ok = True, and {} == {} gives manifest_ok = True. A/B on a venv whose installed requirements directory exists but is empty, req_root the installer's full tree:
merge base -> ok=False manifest_ok=False reason=studio_install_requirements_changed
head -> ok=True manifest_ok=True reason=None recorded requirement_files = {}
A partial tree behaves the same way in miniature: with only studio.txt surviving of seven, head records one digest, reads the same one back, and returns manifest_ok=True. Detecting a venv damaged after or during install is what this manifest is for, and the check now answers "nothing to compare" for exactly that damage.
tests/studio/install/test_install_manifest.py:340-346 creates an empty requirements directory and asserts the resolver returns it, so the weak guard is pinned by a test.
Gate on the file the digests are about:
if (reqs / BOOT_REQUIREMENT_FILE).is_file():The reader half at unsloth_cli/_studio_deps.py:153-158 needs the same change.
"On 2026-08-19 this went red on all four platforms" is false, in two permanent comments
.github/workflows/desktop-app-clean-machine-ci.yml:282 and :533 carry that sentence verbatim. Run 32220770251:
failure desktop linux deb
success desktop windows
failure desktop macOS macos-15
failure desktop macOS macos-26
failure desktop linux appimage
The Windows job recorded dispositions recorded: ManagedReady. The file defines three jobs, macos, linux and windows, and the sentence contradicts the PR description's own "Windows was unaffected" two paragraphs later. "the four POSIX legs" is accurate.
The manifest records prefix but never the requirements root it digested, so this class of mismatch stays silent
studio/install_manifest.py:160-174 writes prefix, python, platform and steps_total, and nothing naming the tree the digests came from. :391 then collapses every disagreement into the single reason studio_install_requirements_changed, which cannot separate "a tracked file changed, repair" from "the writer digested a different tree".
Recording "requirements_root": str(installed_requirements_root(root) or req_root) and comparing it against str(reqs) in verify_install gives the second case its own reason. The key is additive, so MANIFEST_SCHEMA does not move, on the grounds already set out for no_torch at :175-181. That reason is logged by the preflight and grepped by the workflow, so the diagnosis this PR describes recovering by hand from an uploaded artifact would have printed itself in the failing step.
A cd out of $GITHUB_WORKSPACE before bash "$SH" --tauri in the three clean-machine jobs would stop the harness masking it again.
…giene (#9370) `Desktop app clean machine` is red nightly on main, seven AppImage jobs, all with the app's own preflight naming four missing sonames. The tree is not at fault and neither is the test, so this changes only what the verifier says. What is actually happening ------------------------------------------------------------------------ The scheduled run downloads the published release rather than building one. `REL_TAG` is `v0.1.800-beta`, whose Linux asset was uploaded 2026-08-14T15:26Z and never rebuilt. I extracted it: `usr/bin/unsloth-studio` and zero `.so` files, so it resolves webkit2gtk, libsoup, javascriptcoregtk and appindicator from the host. It is a pre-#9113 thin build, provably: the diagnostic it prints lives at line 210 of `build-thin-appimage.sh`, a script #9113 deleted, and the replacement `appimage-apprun.sh` does not contain that string. #9113 ("Desktop: ship a complete Linux AppImage") landed 2026-08-19T13:09Z and also dropped the line that used to install the host runtime into the appimage row, which is correct under a self-contained bundle and is why that row flipped on an unchanged asset. The Aug 18 run was green, Aug 19's red was the ManagedStale incident that #9263 fixed, and Aug 20 is the first scheduled run after #9113. So the asset under test predates the packaging it is being tested against. The first desktop release cut from current main will satisfy these jobs; nothing in the tree needs changing to make that true, and until that release exists this workflow is red for a correct reason. Note this is invisible on a pull request by construction: on `pull_request` the AppImage jobs consume the artifact from `appimage-pr-build`, built from the PR, so #9113 was green when it merged. Only `schedule` and `push` reach the release. The one real defect ------------------------------------------------------------------------ `verify-complete-appimage.sh` run against that asset reports: Complete AppImage does not clear an inherited LD_LIBRARY_PATH It exits 1 correctly, but it fails at the launcher-hygiene check and never reaches the required-component loop, so a bundle that ships NO runtime at all is reported as an environment-variable bug. That is the difference between an hour of diagnosis and a minute of it. The required-component check now runs first and reports every miss instead of exiting on the first, mirroring the AppRun preflight, which names all four sonames at once rather than making the reader rediscover them one launch at a time. On the published asset it now says: Complete AppImage is missing required runtime component: libwebkit2gtk-4.1.so* ... 21 lines ... Complete AppImage is missing 21 of 21 required runtime components; it resolves them from the host Order only. No check is removed and no assertion is weakened. Verified ------------------------------------------------------------------------ `bash -n` clean. A synthetic AppDir with all 21 components present falls through to the launcher-hygiene check exactly as before, so the hygiene checks still run and still fail when they should. Removing only webkit from that AppDir yields `missing 1 of 21`, so the count is real rather than a formatting flourish. tests/security/test_release_desktop_appimage.py: 15 passed.
What was happening
desktop-app-clean-machine-ciwent red on main on all four platforms after four green days. The error said only:The app's own log says what actually happened:
Every fresh desktop install was coming up stale and repairing itself before it would run. Ten seconds, on every new user's first launch. Everything after that is healthy in the log, which is why the failure looked inscrutable rather than serious.
Root cause
The manifest records digests from
REQ_ROOT, which isstudio/backend/requirements/next to whicheverinstall_python_stack.pyran. A desktop bundle carries its own copy.verify_installreads the installed package's copy, because at verify timeinstall_manifest.pyis imported out of the venv, sorequirements_root()resolves there.unsloth_cli/_studio_deps.pylooks in the same place for a foreign venv.Two different trees, compared against each other. They agree right up until a tracked requirement file changes upstream, and then every install that bundle performs is stale for ever:
v0.1.800-beta, cut 2026-08-14unsloth2026.8.18openaiinextras.txtWindows was unaffected, but only by luck of layout, and it would have followed on the next release.
The fix
The digests now describe the tree the verifier will read.
installed_requirements_root()resolves the venv's ownsite-packages/studio/backend/requirements(both the posix and Windows layouts), andwrite_manifestprefers it.A source or
--localeditable install has no copy undersite-packagesand still uses the root it was given. That fallback is load-bearing: it is what keeps an editedstudio.txtinvalidating the manifest on the dev path, whichtest_edited_requirements_invalidate_the_manifestcovers.The diagnostic gap, which is the reason this took so long
The gate printed
saw: ManagedStaleand nothing else. The reason is logged on its own line, and thetail -60in the same step scrolls it away the moment the backend starts logging. The one fact that explained the whole failure had to be recovered by hand from an uploaded artifact.All three log dumps (macOS, Linux, Windows) now grep for
Managed preflightalongsidedisposition=.I deliberately did not relax the "must reach a READY disposition" requirement, even though the app does recover. Accepting the recovery would have made this go green while every user still paid the repair, which is precisely the regression the gate exists to catch. It caught a real one.
Verification
Three new tests, and the central one was mutation-tested: reverting the one-line fix turns
test_the_manifest_records_the_venvs_own_requirements_not_the_installersred.openai>=2.7.2, installed package onopenai==3.2.0, exactly the Security audit: pin openai, and re-review the four digest-pinned entries #9148 delta) and the manifest must describe the installed one, and the install it just described must read as finishedsite-packagescopy still uses the root it was givenlib/python3.x/site-packagesandLib/site-packagestests/studio+tests/studio/install: 4459 passed, 4 skipped.