Skip to content

Record the venv own requirement digests, not the installers - #9263

Merged
danielhanchen merged 2 commits into
mainfrom
fix-desktop-manifest-reqroot
Aug 19, 2026
Merged

danielhanchen merged 2 commits into
mainfrom
fix-desktop-manifest-reqroot

Conversation

@danielhanchen

Copy link
Copy Markdown
Member

What was happening

desktop-app-clean-machine-ci went red on main on all four platforms after four green days. The error said only:

preflight never reported a ready disposition (saw:  ManagedStale )

The app's own log says what actually happened:

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

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 is studio/backend/requirements/ 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, so requirements_root() resolves there. unsloth_cli/_studio_deps.py looks 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:

app under test v0.1.800-beta, cut 2026-08-14
backend it installs unsloth 2026.8.18
what changed in between #9148 pinned openai in extras.txt

Windows 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 own site-packages/studio/backend/requirements (both the posix and Windows layouts), and write_manifest prefers it.

A source or --local editable install has no copy under site-packages and still uses the root it was given. That fallback is load-bearing: it is what keeps an edited studio.txt invalidating the manifest on the dev path, which test_edited_requirements_invalidate_the_manifest covers.

The diagnostic gap, which is the reason this took so long

The gate printed saw: ManagedStale and nothing else. The reason is logged on its own line, and the tail -60 in 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 preflight alongside disposition=.

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_installers red.

  • the two roots are made deliberately different (installer on openai>=2.7.2, installed package on openai==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 finished
  • a source install with no site-packages copy still uses the root it was given
  • the resolver finds both lib/python3.x/site-packages and Lib/site-packages

tests/studio + tests/studio/install: 4459 passed, 4 skipped.

@danielhanchen

Copy link
Copy Markdown
Member Author

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.

danielhanchen and others added 2 commits August 19, 2026 07:20
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.
@danielhanchen
danielhanchen force-pushed the fix-desktop-manifest-reqroot branch from 1e7cb54 to 2bf446d Compare August 19, 2026 07:20
@danielhanchen
danielhanchen merged commit 2e3cc03 into main Aug 19, 2026
51 of 52 checks passed
@danielhanchen
danielhanchen deleted the fix-desktop-manifest-reqroot branch August 19, 2026 08:31
@mahiatlinux mahiatlinux self-assigned this Aug 19, 2026
@mahiatlinux
mahiatlinux self-requested a review August 19, 2026 09:46

@mahiatlinux mahiatlinux left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:558 spawns the installer with current_dir(install_working_dir(home)), which is $HOME/.unsloth (process.rs:1299). ~/.unsloth/studio has no __init__.py, so it is a namespace portion only and the regular package in site-packages wins the import. A real first launch resolves studio to the venv and the two roots are the same directory. The 10-second repair needs a cwd holding a real studio package: the CI job, or a developer running ./install.sh from a clone.
  • "Windows was unaffected, but only by luck of layout, and it would have followed on the next release." install.ps1:5414 and :5462 never locate setup.ps1 at all; they call unsloth studio setup through the venv's CLI, and unsloth_cli/commands/studio.py:685-712 resolves it from _PACKAGE_ROOT under the docstring "No CWD dependency -- works from any directory." Windows cannot take REQ_ROOT from 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.

danielhanchen added a commit that referenced this pull request Aug 20, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants