mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-24 00:04:14 +00:00
37 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
489fab4a71
|
Support OpenCode V2 in unsloth start (#9275)
* Support OpenCode V2 in unsloth start * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Keep stable OpenCode guide coverage * Follow the OpenCode V2 stable release * Honor OpenCode V2 policy and server semantics * Fix OpenCode V2 launch command recipes --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> |
||
|
|
6e57736ad9
|
Stop blaming the recipe for a turn that never came back (#9266)
* Stop blaming the recipe for a turn that never came back
`connection (codex)` fails on main and on unrelated branches, and the two
lines it prints contradict each other:
##[warning] ... judging the turn on its assertions instead of calling it guide drift.
##[error] [guide drift] agent=codex: the documented launch command exited
non-zero (rc=124) ... so the documented flow in start.py drifted.
Neither is right.
run_timed printed the warning unconditionally, but three callers
(connection, resume, attribution-ab) have no assertion that can rescue a
partial turn and treat a cap as fatal on purpose. So it promised the
opposite of what was about to happen, for exactly the callers most likely
to hit it. It now states the fact and leaves the verdict to the caller.
The error is worse, because it sends the reader to the one file that is
not implicated. The transcript shows codex launching perfectly -- correct
provider, correct model, correct profile -- and then sitting on
ERROR: Reconnecting... 1/5
for the full 600s. Nothing about the documented flow had drifted; the
model server never answered. A cap means the launch command was fine and
the turn never came back, which is a different failure with a different
owner.
Still fatal. Waiving a cap here would report "connection OK" for a recipe
that printed a banner and then blocked on a headless prompt, which is the
failure this job exists to catch, and assert_reply cannot tell a finished
reply from a startup banner. Only the attribution changes.
A non-zero exit from the launch command is still reported as drift, which
is the case guide_fail is right about.
Four static guards, since the script needs five agent CLIs and a live
model server to run. All four mutants (run_timed speaking for its callers
again, the timeout routed back through guide_fail, the timeout waived
instead of fatal, a non-zero exit no longer reporting drift) were
reintroduced and confirmed red. Wired into workflow-trigger-lint, the only
job with no paths filter.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Make the waiver guard enforce its own sentence, not a magic number
The shell suite went red on this branch: 'exactly the expected TIMED_OUT
sites (got 4, want 3)'. Worth deciding which of the two was wrong rather
than moving the number.
The heading is 'only file-edit turn 1 rescues a soft timeout', and that
is the invariant worth having. The assertion under it counted every
consultation of TIMED_OUT and pinned the total at 3 -- so it conflated the
single waiver with the fatal checks beside it, and its own comment
admitted as much ('turn 1's guard, PLUS the resume and attribution-ab
fatal checks').
The fourth site this branch adds makes a cap MORE explicitly fatal at
connection. Failing a guard named for waivers because a fatal check was
added is the assertion being over-specified, not the change being wrong.
The neighbouring semantic assertions agree: 'connection guard has no
TIMED_OUT escape' passes.
So the two shapes are counted separately. The waiver is the || form and
is the only one that lets execution continue past a cap; everything else
consults TIMED_OUT to stop. That enforces the sentence rather than a
number, and keeps all the power: a waiver added anywhere still fails.
Mutation-tested against the repaired guard -- a waiver added to the
connection guard, resume's fatal check turned into a waiver, and the
connection check deleted outright are all caught. 44 pass, 0 fail.
---------
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
|
||
|
|
fdf83cbd6a
|
Make apt fail fast, so the retry has something to retry (#9260)
* Make apt fail fast, so the retry has something to retry #9256 bounded the stalls. This says why they happened and stops them. Reading the four logs, every one ends the same way: 04:47:02 Get:5 https://archive.ubuntu.com/ubuntu noble-security InRelease [126 kB] 05:16:29 ##[error]The operation was canceled. Twenty-nine minutes of silence mid-fetch of a 126 kB index file, and two of the four hung on that same file. What came before it is the other half: azure.archive.ubuntu.com was Ign'd four times over thirty seconds, so apt had already failed over through /etc/apt/apt-mirrors.txt to the public archive, which is not provisioned for this fleet. apt did not treat any of that as an error. Acquire::http::Timeout defaults to 120s and is an idle timeout, so a socket that is open and trickling never trips it. Cutting it to 20s with internal retries turns a 29 minute hang into a ~1 minute failure, and that matters beyond speed: the wall-clock kill is what orphans the dpkg lock, so an apt that gives up on its own is one we never have to kill. Not pinning a mirror. The evidence does not support it: in one stall Azure was dead and the public archive hung, in another Azure was serving fine and the transfer stalled at a 13.6 MB package. Neither is reliably better, and the mirrorlist failover is already the right mechanism. It just needed to be allowed to give up. The written config is validated against apt itself (apt-config dump round-trips all four options), and the helper's retry, timeout, giving up, recovery and no-command paths are exercised end to end. * Wait on all four apt locks, and stop racing a slow mirror Two bugs, both mine, both caught by CI on this branch. 1. apt-get update takes /var/lib/apt/lists/lock and nothing else. The helper waited on /var/lib/dpkg/lock-frontend only, so after an attempt was killed mid-update the wait saw a free lock, retried immediately, and produced E: Could not get lock /var/lib/apt/lists/lock. It is held by process 2420 (apt-get) twice in under two seconds. Three attempts, one real one -- exactly the 'a retry that cannot succeed is worse than none' failure the header warns about, in the one case it was written for. It now waits on all four: dpkg's two, lists, and archives. 2. 150s per attempt was racing the mirror, not bounding it. The mirror was degraded rather than dead, so every attempt got killed at the same point and none finished. Two attempts of 360s instead of three of 150: the bound exists to stop an infinite hang, not to beat a slow server. studio-update-smoke's job budget goes 15 to 25 to fit the new worst case; the guard checks that arithmetic. * Ask the image's package lists first, and refresh only if they miss The mirror outage is still live. From this branch's own GGUF smoke: 06:55:33 Ign:4 http://azure.archive.ubuntu.com/ubuntu noble-backports InRelease 06:55:35 Get:5 https://archive.ubuntu.com/ubuntu noble-security InRelease [126 kB] 07:01:11 (nothing, for 5m36s, then the attempt's cap) Azure is unreachable and the public archive it fails over to is not provisioned for this fleet. Acquire::http::Timeout does not save us there: it is an idle timeout, and a server dribbling a byte every few seconds never trips it, so the wall-clock bound is what ends the attempt. The bound is working as intended -- the step now fails in 14 minutes with the reason printed, where before it spent 30 and was reported as "cancelled" with nothing said. But a bounded failure is still a failure, and the operation that fails is one we mostly do not need. `apt-get update` refreshes every index for every suite. The runner image already ships populated lists, and these steps install a handful of ordinary packages. So try the install first, and refresh only when it misses: apt-get install -y X || { apt-get update && apt-get install -y X; } In the common case the slowest and most failure-prone apt operation is never performed at all. When the image's lists really are too stale -- the "Unable to locate package" case the runner-images maintainers warn about -- the update still runs and the install is retried, so nothing is traded away. Not applied to clean-machine-install-ci: a fresh WSL image genuinely has no lists, so install-first would always miss, and a bare machine is that leg's whole premise. The three outcomes were checked as shell rather than reasoned about: resolvable (update never reached, rc=0), not resolvable (update runs, rc=0), and both failing (rc=1, so the failure still propagates under the `bash -e` GitHub runs steps with). |
||
|
|
cabed07f95
|
Bound every apt step in CI, and fix the retry that never ran (#9256)
* Give apt one bounded, retrying entry point, and fix the retry that never ran
Two problems, one of them mine.
The retry I added to the Playwright install last round was dead on arrival.
GitHub runs `run:` blocks as `bash -e`, and I wrote the loop as
timeout --signal=TERM --kill-after=30 480 python -m playwright "$@"
rc=$?
A bare failing command under -e aborts the step then and there, so the second
attempt never ran. The step exited 124 having printed none of its own warnings --
indistinguishable from the unbounded stall it was meant to replace, which is
exactly how it read on #9251's indicator and banner shards, both dying at 8m05s.
The previous version used `if timeout ...; then` and was exempt from -e; changing
it to read the exit code is what broke it. Reproduced both forms under `bash -e`.
The second is broader. `Linux deps` is a bare `apt-get update && apt-get install`
with no bound at all, and on #9251 it sat on the Azure mirror for 28 minutes. Of
24 apt-invoking steps across 13 workflows, 2 were bounded. An unbounded apt step
is not slow, it is silent: it spends the job's whole budget, GitHub scores the
result as "cancelled" rather than a failure, prints no reason, and skips every
step after it.
.github/scripts/retry-with-apt-lock.sh is now the single definition: per-attempt
timeout, retry, dpkg-lock wait between attempts, and exit codes reported honestly
(124/137 as a stall, anything else as the status it actually returned). It carries
no `set -e` and uses `|| rc=$?`, so it works whatever the caller set. Six paths
exercised locally, including the regression above: called from a `bash -e` step,
both attempts still run.
Sizing respects what this file already learned. The note on ui-smoke's
timeout-minutes records that cutting the JOB budget to 20 turned this same stall
from a slow pass into a red build, because one attempt had nowhere to go. The
retry is what changes that, not a shorter wait: 180s is ~14x the 13 seconds
`Linux deps` takes healthy, so only a mirror that is genuinely gone burns all
three attempts. Every step's worst case fits inside its own bound and every step
bound sits under the job's 30, which stays the final backstop and is deliberately
not tightened.
Listed in the workflow's paths filter, since both apt steps now execute it.
The other 20 unbounded apt steps are left alone: every stall observed so far has
been in this workflow, and converting release workflows unreviewed is a worse
trade than leaving them until one of them actually stalls.
* Bound every apt step in CI, not just the two that were noticed
Three jobs were lost to an unbounded apt step in one day, each in a
different workflow and each reported as "cancelled" with no reason:
Chat UI Tests (chat) at 30m18s, Frontend build at 16m38s inside
playwright install --with-deps, and Source lint at 5m02s on a job whose
whole budget was 5 minutes. None of the three said anything about its
actual subject, and the last was noticed only because someone happened
to open a job that said "cancelled".
Routes the remaining twelve on-runner apt steps through the shared
retry helper, gives each an explicit step timeout, and adds a guard so
the next one cannot be added without one. update and install now go as
a single unit: retrying the install after a stalled update just re-reads
the same broken package list.
playwright install --with-deps counts as an apt step, because that is
what it is. Leaving it out by name is what cost the Frontend build job.
Job budgets were raised where the step's bounded worst case no longer
fit: source-lint 5 to 20, api-smoke 12 to 20, frontend build 20 to 40.
A job timeout is a backstop for the unforeseen; using it as the bound
on a known-flaky step is what turns a diagnosable failure into a silent
one.
Not converted, with reasons recorded in the guard: clean-machine-install-ci
runs apt inside bare containers and WSL with no checkout and deliberately
no sudo, and the two steps where apt is the assertion rather than the
setup.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
---------
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
|
||
|
|
06220da4d2
|
Run two short jobs as background lanes of Lint CI, not on their own runners (#9176)
* Run two short jobs as background lanes of Lint CI, not on their own runners
Two workflows each held a runner slot on every commit for a few seconds of
read-only checking:
Unsloth load-orchestrator CI :: test ~33s, its own slot
Lockfile supply-chain audit :: audit ~6s, its own slot
Both now run as background lanes inside Lint CI, which has no path filter and
was already going to occupy a runner on every commit. Three slots become one.
Backgrounded rather than appended as steps, so the lanes overlap the ~65s of
lint instead of adding to it: ubuntu-latest has four cores and the lint steps
are single-threaded. Measured locally on the real step bodies, a 5s lane
alongside 5s of foreground work took 7s total, not 10s.
Absorbing a narrower-triggered job into an unfiltered one can only reduce the
slots a commit takes. The absorbed work now runs on commits that would not have
triggered it, on a runner that was going to exist anyway.
Three things about this were not obvious and are worth recording:
* Each lane needs its own venv. The load-orchestrator lane installs fastapi
and friends while the foreground steps use the interpreter Lint CI's own
pip install populated, and two concurrent installs into one site-packages
is a real race.
* The launch has to detach from the step's stdout and stderr. A background
child inherits those pipes and the step is not considered finished while a
writer holds them: launching a 4s lane took 4.0s before the redirect and
0.0s after. Nothing would have gone red, the overlap would just have
silently disappeared.
* A missing exit-status file has to be a failure. A lane that never started
is otherwise indistinguishable from one that passed.
The payloads live in .github/scripts/lane-*.sh, called by both the lane and the
standalone workflow, so there is one definition. studio-load-orchestrator-ci.yml
keeps workflow_dispatch as an escape hatch; its push trigger had no paths filter
at all, so it was running on every commit to main regardless of the four paths
its PR trigger listed. lockfile-audit.yml keeps its nightly schedule, which is a
different check: it re-reads the lockfiles against advisories published since
the last commit, which no commit-triggered run can do.
tests/studio/test_absorbed_lanes_still_run.py covers the silent failure modes.
Eight mutations verified red, including an uncollected lane, a missing sentinel
read as success, and a launch that stops detaching.
* Run the lane guard on workflow-only PRs
The guard covers work moved off its own runner into a background lane, and
every way that regresses -- a lane launched but never collected, a missing exit
status read as success, a launch that stops detaching -- is an edit to
lint-ci.yml or to one of the two absorbed workflows.
No job's paths filter matches a workflow-only change: workflow-trigger-lint
enumerates specific test modules, and Backend CI matches tests/** and its own
YAML but not the workflow files guarded here. So the suite would first be
collected by Backend CI's unfiltered push on main, after the change had merged.
That matters more than usual because every failure mode here is silent. Nothing
goes red when a lane stops being collected; the absorbed suites just quietly
stop being able to fail anything.
* Run the workflow guards in one pytest, and run the ten that ran nowhere
workflow-trigger-lint is the only job in the repo with no paths filter, which
makes it the only job a workflow-only PR is guaranteed to start. Every other
job filters on source paths: Backend CI matches studio/**, tests/**, scripts/**
and its own YAML, but not arbitrary workflow files.
So a guard that reads a workflow file and is not run by this job has a specific
silent hole. The edit it exists to reject is by definition a workflow-only edit,
and on such a PR it is never collected. It is collected later by Backend CI's
unfiltered push on main, after the change has merged. The guard still works, it
just stops being able to block anything.
Ten modules were in that state at once, including the indicator-parallelism
guard, the GGUF phase-independence guard and the Windows small-checks guard,
each of which reads a workflow and can only be broken by editing one. Three
separate review rounds reported one instance each before the pattern was
visible.
They now run in ONE pytest invocation rather than a step per module. Measured
over these 18 modules: 53.9s as a single call against 300.8s as one each,
because this repo's conftest is expensive to import and a step per module pays
it every time. That also removes the reason the list stopped growing: adding a
guard no longer means adding a step.
tests/studio/test_workflow_guards_run_unfiltered.py keeps the list honest, with
two exemptions carrying their reason (PIL and a local utils helper, neither of
which that job installs). It caught itself on its first run, being a
workflow-reading module that was not yet in the list.
Three mutations verified red: a guard dropped from the list, the invocation
split back into two steps, and the job gaining a paths filter.
* Give the consolidated guard job the cores and the budget it now needs
Absorbing 12 more guard modules into one step made that step the job, and the
job still carried a 5 minute budget sized for the old set. Serial the 21
modules are 209s on a 192-core box, which would have failed on timeout on a
slower runner rather than on anything real.
So: pytest-xdist, `-n 4`, and 15 minutes.
Pinned to 4 rather than `auto` on purpose. ubuntu-latest is a 4-core runner so
on CI the two are identical, but `auto` scales to the host and every xdist
worker re-imports this repo's expensive conftest. Measured here: `auto` spawned
192 workers and took 327s, worse than running serially, while `-n 4` is 42s.
A fixed width behaves the same everywhere and cannot be made pathological by
the machine it lands on.
304 tests, identical results serial and parallel.
|
||
|
|
68815aa888
|
Do not let the signal handler's own logging stop it dying (#9083)
* Do not let the signal handler's own logging stop it dying A cancelled launcher exited 0 instead of dying of its signal, intermittently, and only on loaded CI runners. The cause was in the handler's first line. A signal handler runs on the main thread wherever that thread happened to be. If it was inside a write to stdout, the interpreter refuses the second one: RuntimeError: reentrant call inside <_io.BufferedWriter name='<stdout>'> That is what the handler's opening _log call did. The exception escaped the handler before it could re-raise the signal, main()'s except BaseException caught it, and the process exited on whatever code main computed. A cancelled job then reads as a completed one. It needs the signal to land inside a print, which is why it never reproduced locally and why it took a contended runner to show it. The line that identified it is the diagnostic added in 9079, which printed the launcher's own log on failure. Two changes, either of which is sufficient, kept because they fail independently: logging from the handler is now best effort, falling back to os.write, which goes straight to the file descriptor and takes no lock the interrupted frame could hold; and the death moves into a finally, so nothing above it can prevent the process dying of its signal. The regression test makes the reentrancy deterministic by raising from the handler's log call rather than racing a print. Against the current main it fails with returncode 0, which is the symptom as observed. Worth recording: 9072 attributed this to a transient OSError from release(). That mechanism is real and its fix stands, but it was not what CI was hitting. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Drop the handler's diagnostic rather than blocking on it Raising is not the only way a log line can stop the handler. stdout in CI is a pipe, and if the collector stops draining it a write parks in the kernel instead of failing, so no except and no finally runs. The announcement is the FIRST thing the handler does, before release(), so the kernels would keep billing until something killed the launcher from outside: the one outcome this file exists to prevent. That backpressure is also what leaves the main thread interrupted mid-write, so it arrives with the reentrancy the previous commit fixed rather than instead of it. _log_from_signal now asks whether the descriptor is writable before touching it and drops the line if not. POSIX reports a pipe writable only when PIPE_BUF bytes fit, and these lines are far shorter, so a positive answer means the write completes. Any error from select answers no. The new test reproduces it rather than simulating it: the launcher writes until the pipe is full and the test never reads a byte, so the main thread is asleep inside a write, holding the buffer lock, when SIGTERM lands. Without the guard it hangs the full 120s budget and the kernel is never deleted. 536 kaggle tests pass. * Move the nonblocking check into _log, so release()'s own logging is covered The previous commit guarded only the lines the handler prints itself. release() logs through the ordinary path -- delete_kernel() reports a refused delete that way -- so on a full stdout pipe the handler still stalled, just one frame deeper, before its retry and before the finally that re-raises the signal. Same outcome: a kernel billing until something kills the launcher from outside. The check now sits in _log, gated on a flag the handler sets on entry, so every line printed from that point on drops instead of stalling however it was reached. Nothing changes on the ordinary path, which is every line this script prints before something kills it. The regression test missed this because its fake deletion succeeded silently. It now logs on the way through, which is what a real refused delete does. Remove the guard and the test hangs the full 120s budget with the kernel never deleted; the previous commit's fallback guard does not save it. 536 kaggle tests pass. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Put every stdout line behind the handler's protections, not just the handler's own Two paths still bypassed them. release() warns about a kernel it could not delete with a raw print(), which is emitted on exactly the branch where a kernel is still billing, so a full pipe stalls the handler there before the finally can re-raise the signal. And _log dropped a line when the descriptor was unwritable but still raised when the buffer lock was held, and that reentrancy propagates out of delete_kernel() and out of release(), so a kernel Kaggle would have accepted on its third retry is never asked a third time. So the split is now between what a line IS and how it is written. _write_line carries the protections, _log adds the [launch] prefix on top, and the leaked-kernel warning goes through _write_line directly: through _log it would arrive as '[launch] ::warning ...', and GitHub matches an annotation from the start of the line, which would silently demote the one message that says a kernel is still billing. Three tests, each failing on the unfixed shape: a delete that is refused twice and accepted on the third still makes all three attempts while every write raises; a launcher whose deletes never succeed still dies of its signal with a full pipe; and the warning is read back out of a real main() run rather than from the source. The first version of that last one called the writer directly and passed on the mutation it was meant to catch. 536 kaggle tests pass. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> |
||
|
|
fa6be357dd
|
One multi-turn smoke script for all three operating systems (#9086)
* One multi-turn smoke script for all three operating systems The check ran inline in the Linux, macOS and Windows smoke workflows as three copies of the same script, and being three copies is how one of them stopped checking. On 2026-05-22 an unrelated event-loop fix (#5669) turned the Linux copy's determinism assertion into a printed warning. macOS and Windows kept it, and are otherwise identical in logic: diffed, the only differences are that warning and some comments Windows had dropped. Nothing compared the three, so for three months the leg that runs on every pull request was the one not asserting, while the two that still asserted run rarely. The script moves to .github/scripts/studio_smoke/multi_turn_chat.py and all three call it. Nothing in it was platform specific; the only thing that differed per caller is the port, which it already read from BASE_URL. That removes 264 lines of YAML heredoc. It reads no environment and imports no SDK at module level, so the checking half is importable, and the new tests exercise it rather than grepping it: divergence between the two runs raises, trailing whitespace alone does not, an empty reply raises, and a transcript with no history grounding raises. Reverting the script to the warning form fails one of them; re-inlining a copy into a leg fails two. Worth stating plainly: this re-enables on Linux an assertion that has not run there since May. If greedy decoding is genuinely not reproducible on that leg, it will now fail rather than print. That is the right way round, and it will be one failure in one place rather than three copies to reconcile. The guard runs from workflow-trigger-lint.yml, which carries no paths filter, because a pull request that re-inlines one leg edits a workflow and nothing else. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Assert both runs are non-empty, not just the first The stripped comparison cannot tell the two apart: a second run returning "" against a first returning "\n" compares EQUAL, so a server that stopped answering halfway through printed OK. The Linux copy asserted both before this was consolidated; the macOS copy it was taken from asserted only the first, and I kept that half. The test now covers an empty SECOND run and the exact whitespace-against-nothing pair the comparison is blind to. Dropping the new assertion fails it. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> |
||
|
|
5307992416
|
Keep the killed-by-signal exit status when release() fails (#9072)
* Keep the killed-by-signal exit status when release() fails A cancelled launcher reported success. The signal handler deletes the kernels and then re-raises the signal so the status still reads killed, but the delete was outside any try: a raise there propagated out of the handler into whatever the main thread was doing, main()'s except BaseException caught it, and finish() called release() a second time. When that second call worked, which is what a transient OSError out of a subprocess spawn on a loaded runner looks like, main RETURNED 0 and the job read as completed. Caught on a contended runner rather than by inspection, and reproduced by failing the first delete only: returncode 0 instead of -SIGTERM. The delete is now best effort, retried once, and the death by signal happens either way. If both attempts fail the slugs stay in the registry and the next launcher's orphan sweep reclaims them, which is the same path a kill -9 already takes. The subprocess waits in the suite move from 30s to a named budget of 120s. They guard against a launcher that never dies, not against latency, and the repo suite now runs four of these at once on a four-core runner. * Make the stall outlast the death budget, and assert the signal did the killing Raising the wait to 120s put it past the 60s stall the launcher sits in, which defangs the tests it was meant to make robust: a handler that swallows its signal leaves the process asleep and then resuming, so it wakes, runs finish(), deletes the kernels through the ordinary path and exits INSIDE the wait. The deletion assertions are satisfied by that, so those tests would have passed on a launcher that ignored the signal entirely. The stall is now a named 900s against the named 120s budget, and a test asserts the relationship rather than leaving it to whoever next tunes one of the two numbers. The deletion test also asserts the exit status now. Without it, the deletion is satisfied by finish() doing its usual work, so nothing there said the signal was what caused it. Verified by making the handler return instead of re-raising: with the stall at 900s that fails all four signal tests, and with it at 60s the relationship test fails as well. * Tighten the comments added here |
||
|
|
482abfabfa
|
Give Unsloth Studio its first CUDA coverage, on a Kaggle T4 (#8489)
* Run deterministic notebook smoke tests on real Kaggle T4s
The notebooks are written for a T4: sm_75, fp16 with a gradient scaler,
xformers rather than flash-attention, 16GB. Nothing in CI runs on that
card, so a regression that only appears there reaches users first.
Adds a gated job that launches one Kaggle GPU session and runs the smoke
payload once per T4. The account has a 60h weekly budget shared with
other consumers, so the job is a deliberate minority consumer of it:
paths filters (only 9.9% of a week's 444 main commits touch unsloth/),
a ~10% sampling draw keyed on the run id so a re-run cannot reroll, a
quota floor read live from Kaggle's own API, and a stand-down when the
account already has a kernel in flight. Expected cost is 5-7 GPU-hours
a week.
Only a payload that ran on a T4 and disagreed with its assertions turns
the check red. Throttling, the 2-kernel concurrency cap, a dead session
or a download that would not complete are all warnings, because a free
external service blocking merges would be ignored within a week.
The payload asserts three things, worth different amounts. Two fresh
processes must agree bitwise on per-step loss and grad_norm; that one is
exact, and it is fresh processes because two in-process cycles disagreed
from the first step while two processes agreed on all ten. Greedy
decoding must emit the canary string exactly, which catches trained
weights never reaching the generate call. Metrics must sit inside a band
around a committed reference, which is a tolerance and never an equality,
since fp16 reduction order moves with the driver and the libraries.
Ten steps rather than three: under fp16 the scaler skips the first two
overflowing steps, so a three-step run lands one real update and the
canary never forms. The dataset is supplied as prompt/completion columns
so the loss falls only on the answer.
* Make the payload cells the T4 rejected actually run there
Two launches on real Kaggle T4s died in the scaffolding before a training
step ran, both of them in generated code that nothing had ever parsed.
The dependency probe imported unsloth_zoo before unsloth. Zoo's __init__
ends with a find_spec("unsloth") guard and raised "Please install Unsloth
via pip install unsloth" on a session where unsloth was installed and
imported cleanly one entry later, so the payload aborted over a dependency
that was not missing. Probe unsloth first, and invalidate the import caches
after the pip installs that this interpreter did not see at startup.
The reference argument was generated as a shell fragment and spliced into
the middle of a Python list literal, with doubled braces that left ROOT
unsubstituted. The cell was a SyntaxError, and the workflow passes
--reference on every path, so no payload could ever have reported.
Verified locally by executing the regenerated run cell end to end: two
fresh processes, bitwise identical metrics, canary exact.
* Bound the in-flight kernel survey by session length, not kernel count
The concurrency check status-checked the twelve most recently run kernels,
which is a sample rather than a search: a kernel that started three hours
ago and is still running is missed the moment twelve newer ones have since
run, and the push then fails at the account capacity cap and is reported as
infra.
Kaggle kills a notebook session at twelve hours, and the listing is sorted
by last run time, which for an unfinished kernel is when it started
(measured: a kernel pushed at 10:05:19Z lists as 10:05:19.297). So walking
until the first entry older than that ceiling covers every kernel that can
still be in flight, and stopping there is exhaustive rather than arbitrary.
An hour of slack is added for clock skew.
Two silent ways to read an unanswerable question as a green light are
closed with it. Hitting the page cap now marks the survey incomplete, and
a survey where no status could be read at all is unknown rather than idle.
Both stand down, which is the cheap direction: the next commit draws again
in minutes.
On the live account this now costs three status calls instead of twelve.
* Stop the reference band check from passing NaN unconditionally
The band comparison did its NaN handling by accident, and it got it wrong.
Under fp16 the gradient scaler logs a NaN grad_norm on every step it skips,
so a committed T4 reference genuinely contains NaN. Left to the arithmetic,
abs(x - NaN) is NaN and NaN > tolerance is False, so those steps passed
whatever they held, including the case the check exists for: a step that
used to overflow and no longer does. Compare NaN to NaN as equal and NaN
against a number as a deviation, and treat a field logged on one side only
as a deviation too, since no tolerance covers a change in shape.
The mapping from an out-of-band verdict to the failure list moves into
reference_failures() so the path that turns the job red can be tested
without a GPU.
The references README now says plainly that no reference is committed, that
one must not be borrowed from other hardware, and how to lift one out of a
green run's evidence rather than spending a session on it. It also records
a measurement: the absolute floor never engages on this trajectory. The
smallest value on the ten-step curve is a loss of 0.1428 against a floor of
0.05, so the floor changes no verdict today and is kept for a configuration
that does go lower.
* Name the cause when a kernel finishes and reports nothing
A kernel that ends COMPLETE with no payload report is the hardest outcome
to read: the summary has no metrics to show and the reason sits in an
artifact nobody downloads. It is also the outcome both real T4 failures
took.
Kaggle returns the kernel log as a JSON array of stream records rather than
as text, so the interesting line arrives split across dozens of them and
reading the file directly shows a wall of JSON. Flatten it, filter to the
driver and payload sentinels plus tracebacks, and fold the tail into the
job summary. Checked against the evidence of a real failed run: the
SyntaxError that killed it is now visible without leaving the summary.
* Cover the four T4 CI fixes with CPU-only tests
The generated cells are now compiled on both the with-reference and
without-reference paths, which is what would have caught a SyntaxError that
instead cost a Kaggle session, and the dependency probe's module order is
asserted.
The in-flight survey gets a fake Kaggle client and the case that motivated
it: one kernel still running behind forty newer finished ones, which a
count-bounded scan misses and a time-bounded scan cannot. Also the window
boundary, timezone-aware and missing timestamps, the page cap, and the
difference between some statuses unreadable and all of them.
The band check is now proved to fail rather than assumed to work: a
perturbation half a band-width past the limit, a moved scaler skip pattern
in both directions, a field that stopped being logged, and a length
mismatch, each asserted through to the failure list. Three tests perturb
the committed reference itself and skip, saying so, until one exists.
41 passed, 3 skipped.
* Parse every generated cell on every build path before spending a session
Two of the three Kaggle sessions spent so far died on generated code that
nothing had ever parsed. The compile check now builds all three paths the
generator has, using the workflow's own argument list verbatim, including
the one where a committed reference exists and is carried inline as a
fourth file: that path becomes live the moment a green run supplies the
file, and would otherwise first be exercised on Kaggle.
Parsing alone is not enough. A template hole that substitutes to a bare
identifier parses and then NameErrors, which costs the same session, so
every cell is also checked in execution order for names nothing before it
defines. Both checks were confirmed to go red: the first on the exact
shell-fragment-in-a-list-literal defect that cost session three, the
second on a run cell reading a name no cell binds.
The carried sources are also decoded the way the kernel will decode them
and compared byte for byte with the repo, so the T4 cannot quietly run
something other than what is committed.
* Commit the T4 reference the first green Kaggle run produced
Kernel danielhanchen/unsloth-t4-ci-e3c6661f, terminal state COMPLETE, both
payloads passing on their own Tesla T4 of one session. Two fresh processes
agreed bitwise on all ten steps on both cards, max_abs_diff exactly 0.0 for
loss and for grad_norm, and the two cards independently produced the same
ten values as each other, so four processes agree rather than two. All four
cycles emitted the canary exactly. The scaler skipped steps 1 to 3 every
time, which is the behaviour the ten-step length exists to accommodate.
The file is reports[0] of that run, copied whole by the recipe the README
already documented, and nothing in it is from other hardware.
One assumption the run contradicted, now recorded rather than repeated: the
session had no xformers. unsloth installs --no-deps, unsloth_zoo does not
carry xformers and the Kaggle image does not either, so the banner read
Xformers = None and this trace is the fallback attention path. Adding
xformers to the install would move these numbers and require a recapture.
The floor's smallest observed value moves from 0.1428 to 0.0871, still
above the 0.05 denominator floor, so the floor stays inert and the test
that re-derives that keeps passing.
The three tests that perturb the committed reference now run instead of
skipping. 45 passed, 0 skipped.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Name the in-flight tolerance the Kaggle gate stands down on
The gate declines to launch whenever any kernel of the account is in
flight, which is stricter than Kaggle's 2-kernel cap requires. That is
the intended policy, so it is now a named constant with the tradeoff
written next to it rather than an implicit property of the code, and a
--allow-in-flight argument that cannot be raised to the cap itself.
Default behaviour is unchanged.
Also files the workflow under 'never' in ci-preempt.json: cancelling a
run does not stop the Kaggle kernel it already pushed, and the orphan
then bills quota to its own ceiling with nobody watching.
* Run three steps, and refuse a reference captured at another count
max_steps drops from 10 to 3. Measured consequence: under fp16 the
gradient scaler starts at 65536 and skips every step it overflows on,
and the committed reference shows steps 1, 2 and 3 all skipped, so a
3-step run of the old configuration applied zero optimizer updates.
--init-loss-scale pins the scaler below the overflow point so those
three steps are real updates, and optimisation_failures() fails a run
whose every step was skipped rather than letting it report as training.
The committed reference was captured at 10 steps and no longer
describes the run. check_reference now reads the max_steps the
reference records and refuses to compare across counts, as a failure
with both counts named -- including when the reference does not say
what it was captured at. A quiet pass there would be a green check
comparing a run against a curve it has nothing to do with.
The reference is therefore stale until one recapture run. The workflow
gains skip_reference_band for exactly that dispatch, and the recipe is
in references/README.md.
Also brings the workflow onto the repo's conventions: pinned action
SHAs matching the rest of CI, a workflow-level concurrency group that
deliberately does not cancel in progress, a fork guard on the gate
job, typed dispatch inputs and an artifact name without the run id.
* Keep the run at ten steps, and say why three does not work
The committed reference is the evidence: the fp16 scaler reports grad_norm
NaN at steps 1, 2 and 3 and a finite value from step 4. It starts at 65536,
halves on each overflow, and skips the step it overflowed on, so a 3-step
run applies zero optimizer updates. Loss stays around 10, the canary never
forms, and the band would compare three points of a curve that never moved.
Shortening also saves nothing worth having: a launch costs about 0.08h and
that is almost all pip install, not training.
The step-count guard, the all-steps-skipped guard and --init-loss-scale all
stay, since they are what makes a shorter run possible later and what stops
a vacuous one reporting green. The pin is off by default so the committed
reference keeps applying.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Turn one T4 payload into four, and pair a pinned control with a canary
The workflow ran a single tiny SFT payload on both cards of one Kaggle
session and spent about 1.4 GPU-h a week doing it. The budget is now 40,
and the question asked of it is wider: catch regressions in trl,
transformers, accelerate, peft, bitsandbytes, torch and vllm, and catch
torch.compile falling over on sm_75.
Four legs now, two kernels of two T4s each, described once in
.github/scripts/kaggle_t4_ci/legs.py rather than restated in YAML:
control the existing tiny Qwen2.5-0.5B determinism run, pinned
canary the same run on the newest library set Unsloth's own
constraints allow
gptoss gpt-oss-20b LoRA, for torch.compile and the forced float32 path
grpo Qwen3-4B GRPO through a vLLM engine on the same card
control and canary are one instrument rather than two tests. Same payload,
same seed, same data, same step count, on the two cards of the same
session; the only difference between them is the installed versions. A red
canary beside a green control is a library release, and the job summary
already puts the two version sets side by side so the bisect is a diff
rather than an investigation. The reference band applies to the control
alone: two library sets do not produce one fp16 trajectory, so the canary
asserts what does not depend on versions instead.
The pins are evidence rather than preference. transformers and trl are the
pair the committed reference was captured with; peft, accelerate and
bitsandbytes come from probe kernels 8161ceb9 and 7ab727f1, two independent
T4 sessions that ran the identical base install and agreed. torch stays
unpinned and recorded, because it comes from the Kaggle image and replacing
it is the failure mode the grpo leg spent two probes on.
The gate now separates this workflow's own kernels from everybody else's.
It takes both of Kaggle's concurrency slots, and only when the account is
otherwise idle: one kernel belonging to anyone else still stands it down
entirely, which is the same policy as before stated at the right grain.
* Leave the vLLM leg unwired, and stop trusting Kaggle's own kernel timeout
Three probe sessions tried to run the Qwen3-4B GRPO leg on a T4 and none of
them reached a training step. None of them failed for a reason to do with
sm_75, memory or GRPO either: vllm==0.11.2 pins torch==2.9.0, so the leg has
to replace the Kaggle image's torch, and that is what killed all three.
With the image's site-packages visible, pip treats torch's pinned NVIDIA
runtime packages as already satisfied by the copies belonging to 2.10, and
the result is a torch that installs cleanly and cannot be imported --
libcusparseLt.so.0 first, then ncclCommWindowRegister once that one was
named explicitly, identically on vllm 0.11.2 and 0.15.1. An isolated venv
resolving the whole stack got further and then the session wedged.
So the leg keeps its payload, its assertions and its install, and moves to
legs.UNWIRED with the three kernel slugs and what each one measured. Wiring
it now would make the check permanently red and spend the budget doing it.
The second kernel keeps its spare T4, which costs nothing, and that is where
the leg goes when the install works.
The third probe also cost about an hour of quota teaching us that the
push-time kernel timeout is not a budget control. Pushed with -t 5400, its
own nbconvert crashed at t=406s and the session sat in RUNNING for over two
hours -- past that ceiling and past the launcher's own deadline, because one
status call had blocked with no socket timeout to bound it. Deleting the
kernel by hand took the account's used-hours figure back down, which is the
measurement that settles which control is real. The launcher now sets a
socket timeout and deletes every kernel it pushed on every path out, and
the workflow header says which of the three bounds is load-bearing.
Sampling recomputed on the payload that actually ships: 40%, from measured
per-kernel cost rather than the old estimate, with the note that wiring a
fourth leg means recomputing it rather than editing one line.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Re-solve the GRPO leg's install so it stops replacing the image's torch
Three probe sessions never reached a training step, and all three died the
same way: vllm==0.11.2 pins torch==2.9.0, so the leg had to replace Kaggle's
torch 2.10.0+cu128, and pip treats the image's NVIDIA runtime packages --
which belong to 2.10 -- as satisfying the new torch's pins. That imports as
libcusparseLt.so.0 missing, then as libtorch_cuda.so undefined symbol
ncclCommWindowRegister one package along, then as an isolated venv that spent
an hour of quota resolving a CUDA stack and never produced payload output.
None of that is a question about sm_75, memory or GRPO. It is one line: no
vLLM release in the 0.11-0.16 range pins the torch the image ships. Releases
0.17.0 through 0.19.1 pin torch==2.10.0 exactly, so pin 0.19.1 and the leg
replaces nothing, keeps system_site_packages, and installs like any other.
Drop xformers with it. Its vLLM attention backend was deleted in 0.12.0, so
it would be a package nothing selects. sm_75 has no FlashAttention and no
FlashInfer, and the ladder in vllm/platforms/cuda.py falls through both to
TRITON_ATTN; the leg names that in VLLM_ATTENTION_BACKEND so a release that
reorders it goes red here rather than quietly selecting something else. 7.5
is still in CUDA_SUPPORTED_ARCHS at v0.19.1 and fp16 is a supported dtype
below capability 8.0.
Still unwired. Two things are static evidence rather than a run: whether
0.19.1 starts on sm_75 at all, and whether 8GB of 16-bit weights plus an
engine plus a LoRA trainer fit in 14.56GB. UNWIRED says so in those words,
and a test asserts the note still names the pin and still says what is
unknown, so nobody wires this off prose that outlived its measurement.
Also ports the vacuous-test fix from the stacked Studio branch: pre-commit.ci
reformatting default=10 to default = 10 stopped the payload regex matching,
so the step-count agreement check was comparing nothing.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Stop the GRPO leg JIT-compiling flashinfer on a machine that cannot link it
First probe on the re-solved install, kernel unsloth-t4-ci-e2d9ce9b. The
install works: torch stayed at the image's 2.10.0+cu128, vllm 0.19.1
installed and imported, xformers was absent as intended, and the payload ran
on a real Tesla T4 reporting capability 7.5 with TRITON_ATTN accepted. It
reached engine construction, which is further than any earlier probe got by
the whole width of the install.
It died in flashinfer 0.6.6's JIT, and not for an sm_75 reason. All three .cu
files compiled cleanly for -gencode=arch=compute_75,code=sm_75. The LINK
failed:
/usr/bin/ld: cannot find -lcuda
-L/usr/local/cuda/lib64/stubs is already on the command line, so the image
has no driver stub libcuda.so, only the runtime libcuda.so.1. Nothing about
that is fixable from a payload.
Set VLLM_USE_FLASHINFER_SAMPLER=0 so nothing JITs. The sampler has a native
path, and skipping the build also saves a four-file nvcc compile inside a
session billed by wall clock. UNWIRED records what the probe measured and
narrows what is left to one question: whether the engine builds now.
* Make -lcuda resolvable so flashinfer can link what it compiled
Second probe, kernel unsloth-t4-ci-916d5986, failed identically to the first
with VLLM_USE_FLASHINFER_SAMPLER=0 set. That is the informative part: the
JIT is not reached only through the sampler, so switching off one consumer is
whack-a-mole.
The failure was never sm_75. Both sessions compiled all three .cu files
cleanly for -gencode=arch=compute_75,code=sm_75 and died at the link:
/usr/bin/ld: cannot find -lcuda
-L/usr/local/cuda/lib64/stubs is already on the command line; the image ships
no libcuda.so at all, only the versioned libcuda.so.1, and the linker will
not resolve -lcuda against a soname. Normally the toolkit's driver stub fills
that gap; this image has the directory and not the file.
So symlink libcuda.so at the real driver in a scratch dir and put it on
LIBRARY_PATH, which gcc and ld search for -l. No root, nothing written into
/usr/local, and it fixes every flashinfer op rather than one. Linking against
the real driver instead of a stub is correct here: the driver is present,
which is the whole reason a stub would have substituted for it.
Also widen the captured traceback to head AND tail. The last probe's
6000-char tail was entirely ninja's own output, so the Python frames naming
the caller were exactly what got dropped.
Probe 2 also measured the fit question the notebook raised: peak reserved
7.96GB of 14.56 with load_in_4bit false, so 16-bit weights are not what is
short of room here.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Only count a libcuda the linker will actually search for
Kernel unsloth-t4-ci-d0d480b6: the shim reported already_linkable and did
nothing, and the link failed anyway. It had found libcuda.so in
/usr/local/cuda/compat -- which is real, and which nothing passes with -L.
The failing ninja line names /usr/local/cuda/lib64 and
/usr/local/cuda/lib64/stubs, and those are the only two directories that can
answer -lcuda.
So the precondition now checks exactly those two, and compat becomes what it
is actually good for: a symlink TARGET when ldconfig and ctypes both come up
empty. A library the linker will not search for is not a library the linker
can find.
Two tests: one reproduces the compat-only machine and asserts the shim is
built anyway, one pins the searched directory list, since widening it is
precisely how the check went wrong.
* Give the base model a chat template, and record what the link shim did
The link shim works. Kernel unsloth-t4-ci-27b0dc2e reported
libcuda_shim: needed true, applied true,
searched [/usr/local/cuda/lib64, /usr/local/cuda/lib64/stubs],
real /usr/local/cuda/compat/libcuda.so
and flashinfer is gone from the failure entirely. Memory went from 7.96GB to
11.36GB of 14.56, which is the vLLM engine actually loading, and the run
reached trainer.train() -> _run_epoch -> training_step. Both questions
legs.UNWIRED was holding this leg for -- does vLLM start on sm_75, does it
fit in 14.56GB -- are answered by that.
It then failed on the payload's own gap:
ValueError: Cannot use chat template functions because
tokenizer.chat_template is not set
unsloth/Qwen3-4B-Base is a base model and ships none. The notebook this leg
comes from solves it with an SFT priming stage that installs a template
before GRPO starts; this leg has no priming stage, so it sets a minimal
ChatML template directly and records in the report which of the two worlds
the run was in.
The base model stays. GRPO on an instruct model would measure the instruct
tuning as much as the run, and the payload's rewards are format-and-digit
rewards a base model can move inside three steps.
A test renders the template rather than matching it as a string: a template
that does not render would trade a failure at step 1 for a failure at step 1
with a longer traceback.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Wire the GRPO leg: it runs on a T4 and the rewards move
Kernel unsloth-t4-ci-53efcc4e passed on a real Tesla T4. Step 2 logged
reward_std 0.707 and grad_norm 0.772, peak 13.60GB of 14.56GB, three steps
in 192s, and fast_generate returned coherent text. reward_std is the
criterion rather than loss, because TRL's GRPO loss is ~0 by construction at
num_iterations=1 and beta=0 and would pass on a run that learned nothing.
The config in args is the one that fit, not the notebook's. Two probes with
seq 2048, 4 generations, rank 32 and utilization 0.9 died in the backward at
unsloth_zoo/gradient_checkpointing.py:1013, peaking at 15.97GB in 16-bit and
19.25GB in 4-bit. 4-bit alone is not the lever: it peaked HIGHER, because
quantizing weights does nothing for activations while utilization 0.9 still
hands vLLM ~13GB up front, and UNSLOTH_VLLM_STANDBY returns the weights
during training but not the KV cache reservation.
An earlier probe reported this as 'CUDA error: an illegal memory access
was encountered' from empty_cache() inside vLLM standby. That was the async
shadow of the same OOM; CUDA_LAUNCH_BLOCKING=1 produced the honest error.
UNWIRED is now empty. The test that read its grpo entry is replaced by the
invariants that outlive any one leg: every leg is wired or explained, nothing
is both, and an unwired note says what is open. Two new tests pin the config
and the vLLM pin, so restoring either to a value that OOMs fails here rather
than on Kaggle.
* Add a CUDA_LAUNCH_BLOCKING switch to the GRPO payload
An illegal memory access is reported at whatever CUDA call synchronises
next, which on the first T4 GRPO run was empty_cache() inside vLLM standby,
nowhere near the kernel that faulted. Re-running with this flag produced the
honest error instead: torch.OutOfMemoryError in the backward. That is what
turned an unexplained CUDA fault into a memory budget, and the budget is now
the leg's config.
The environment variables are set before the first import torch in the
process rather than merely before train(), because they have to precede CUDA
context creation. It serialises every kernel launch, so it stays a switch and
never a default, and it is recorded in the report because it changes what a
timing means.
* Give Studio its first CUDA coverage, on a Kaggle T4
Every Studio workflow in this repo runs on ubuntu-latest, macos-15 or
windows-latest. macOS gives Metal and MLX some hardware; the CUDA path is
exercised by nothing at all, and studio-inference-smoke.yml uses a 270M
GGUF precisely because it has to decode on a CPU.
tests/kaggle/studio_gpu installs Studio the supported way on a Kaggle T4,
starts it headless, and asserts three things a CPU runner cannot: that a
GGUF is actually offloaded to the GPU rather than falling back to CPU and
returning text anyway, that a LoRA training run completes and leaves an
adapter on disk, and that a GGUF export against a CUDA llama.cpp produces a
file that loads. The repo's existing playwright_chat_ui.py then drives the
browser path, last, because it stops the server on its way out.
The gate and the launcher are the notebook leg's, reused unchanged: the
payload prints the same report line, so the transport needed no edit. The
two legs share a concurrency group because Kaggle's 2-kernel cap is per
account and is not workflow-aware.
Sampled at 4 percent of a filter measured at ~760 eligible invocations a
week, which is ~30 launches and ~23 GPU-h against the notebook leg's 15-20
and 1.4. The rate is lower and the spend is fifteen times higher because
the eligible stream is five times larger and a launch costs nine times as
much; the arithmetic is in the workflow header.
* Budget the two Kaggle GPU legs against one account, not two
The Studio leg's budget block was solved against the notebook leg's numbers
from before that leg was re-tuned: 0.08 h per launch, 10 percent, ~1.4
GPU-h/week. The notebook leg now costs 0.25 h at 40 percent for up to 23.1
GPU-h in a busy week, and Studio was sized at ~23 of its own. Two legs at 23
each is 46 of the 50 hours CI is allowed on a 60 h account, with the entire
margin spent on the cheaper one, and the two reserve floors were picked
independently.
Re-solve both against one shared allowance: Studio 35 h, notebooks 15 h.
Studio goes to 5 percent (~38 launches, ~28 GPU-h) and notebooks to 15
percent (13-35 launches, 3.3-8.7 GPU-h). The reserves now encode priority
rather than each leg's own ceiling: notebooks stand down once the account is
35 h spent, Studio runs on to 50.
The percentage is the one axis where Studio reads lower, and it cannot be
made to agree. Studio's eligible stream is ~3.3x larger and each launch
costs 3x more, so a point of rate is worth about ten times as much there;
matching the notebook leg's 15 percent would be ~86 GPU-h a week. Both
budget blocks now say so, and a test re-derives the joint spend from the two
headers instead of trusting either total.
Also fix a test that had gone vacuous: pre-commit.ci reformatting
default=10 to default = 10 stopped the payload regex matching, so the step
count agreement check was comparing nothing. Match count is asserted now.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Write down where the Kaggle sampling percentages come from
The percentages in the two workflows were arithmetic nobody could check. This
records the measurements they came from, taken 2026-08-11 over a 28-day
window: 985 commits to main, 1157 PRs, and 8 of 60 sampled PRs touching the
paths a T4 training run could regress, so a 13% path-filter rate and roughly
75 candidate events per week.
Against 30 GPU-h/week for one account at ~0.75 GPU-h per launch, the ceiling
is about 33 launches per week if everything went to one workflow. The 15/5
split and the 25/10 reserve-hour floors follow from that, and the file says
what would change them: a second account, a wider path filter, or a session
cost above ~1 GPU-h.
* Give the GRPO leg its own kernel: pairing it with gpt-oss fails
Kernel unsloth-t4-ci-70a2f4eb ran the wired pair on the two cards of one
session. gptoss passed: 32 unique graphs, 779 calls captured, 2 graph breaks,
peak 12.78GB. grpo failed with the illegal memory access again, at exactly
the 13.60GB peak at which it PASSED alone on kernel unsloth-t4-ci-53efcc4e,
same config to the flag.
Same peak, different outcome, so the constraint is not GPU memory: the driver
pins one card per payload. gpt-oss offloads to host RAM and vLLM wants host
RAM, and a Kaggle session has one host.
A third kernel costs a queue slot rather than quota, since Kaggle runs two at
a time and bills wall clock per session. That is cheaper than a leg which
fails half the time for a reason the report cannot show.
Two tests pin the rule in both directions: grpo must not share a kernel with
gptoss, and control must still share one with canary, because those two are a
matched pair whose comparison is the whole instrument.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Unwire the GRPO leg: running it alone reproduced the failure I blamed on pairing
I gave grpo a kernel of its own earlier today on the reasoning that sharing a
session with gpt-oss was what broke it. It had failed paired
(unsloth-t4-ci-70a2f4eb) and passed alone (unsloth-t4-ci-53efcc4e), so pairing
looked like the variable, and I wrote two tests pinning the separation.
That reasoning was wrong. Running the leg ALONE again
(unsloth-t4-ci-c98f14be, built from this branch with --legs grpo) reproduced
the paired failure exactly:
unsloth_zoo/vllm_utils.py:601 sleep() -> torch.cuda.empty_cache()
torch.AcceleratorError: CUDA error: an illegal memory access was encountered
engine_built false, peak 13.8GB/13.6GB of 14.56GB. The passing session and both
failing ones agree to the digit on every recorded version -- torch 2.10.0+cu128,
transformers 5.5.0, trl 0.24.0, peft 0.19.1, vllm 0.19.1, unsloth 2026.8.15,
zoo 2026.8.10 -- on the same peak, with UNSLOTH_VLLM_STANDBY=1 throughout. One
contrasting observation was never enough to blame a shared host.
So what three sessions show is an intermittent illegal memory access on Turing,
one pass in three, not a pairing effect. A leg that passes one session in three
cannot tell CI anything: it would go red for a reason no reader could act on.
grpo moves to UNWIRED with the three session ids and the open question written
down, rather than back into a kernel.
The note keeps what the leg already earned -- the vLLM/torch pin, the attention
backend, the libcuda shim, the chat template, the T4-sized config -- so
re-wiring means answering the IMA rather than redoing the install.
test_grpo_does_not_share_a_session_with_gptoss is replaced rather than deleted:
it asserted the opposite thing for the disproven reason, and would now pass
vacuously since grpo is in no kernel at all. The replacement pins that grpo
stays unwired and that the note still names all three sessions, so the evidence
cannot be dropped on the way back in.
145 passed.
* Retire the bootstrap password at login, so the session can reach the routes
First hardware run of this payload, Kaggle kernel unsloth-t4-ci-412345d2 on two
Tesla T4s: preflight, studio_ready, authenticate and the Playwright chat_ui
driver all passed, and the four assertions the payload actually exists for did
not run.
gpu_inference: POST /api/inference/load -> HTTP 403: {"detail": "Password change required"}
lora_training: POST /api/train/start -> HTTP 403: {"detail": "Password change required"}
A bootstrap account is created with must_change_password set, and
get_current_subject turns that into a 403 on every route except the
password-change one. So login yielded a token that authenticated and could do
nothing, and the login step reported success while inference, tool calling,
training and export were all unmeasured behind it.
login now completes the change when Studio's own Token says
must_change_password, and carries the token that /api/auth/change-password
mints. The replacement is secrets.token_urlsafe(24): never whitespace, which
the route rejects, and never equal to the bootstrap value, which it also
rejects. It is not returned or stored -- nothing needs it again.
Four tests, all off-box against a scripted HTTP layer:
the change happens, and the session carries the post-change token
an account already past the gate does NOT get its password rotated
a refused change raises instead of carrying on with a token that cannot act
a change that answers without a token raises
The third is the one that matters: continuing past a failed change is how the
first run produced four unmeasured assertions and no explanation.
Two things this does NOT fix, recorded so the next run is read correctly.
gguf_export reported install_kind=None, i.e. no llama.cpp install under
STUDIO_HOME at all. That may well be downstream of the 403 rather than
independent: POST /api/inference/load is what pulls a llama.cpp for a GGUF
model, and it never ran. The collected kernel.log is 223 lines and holds none
of the install output, so this is not settled either way -- the re-run answers
it.
gpu_inference also failed its own evidence test: text came back, but no probe
could show the GPU was used. The payload calls that a failure rather than a
pass, which is the behaviour I want kept.
89 passed.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Hand the UI driver the password the session actually holds
Second hardware run, kernel unsloth-t4-ci-9ddd8ae4. Retiring the bootstrap
password fixed three assertions and broke a fourth:
gpu_inference FAIL -> PASS
tool_calling FAIL -> PASS
lora_training FAIL -> PASS
chat_ui_driver PASS -> FAIL: "the bootstrap password is gone, so the driver
cannot log in"
My own doing. assert_chat_ui read $STUDIO_HOME/auth/.bootstrap_password off
disk, and authenticate() has to retire that value to get past Studio's forced
password change, so the file is stale by the time the driver runs. The driver's
first phase rotates the password itself and asserts the old one stops working,
so it needs the value the session is CURRENTLY authenticated by -- not the
seeded one.
login now leaves that value on Studio.password and assert_chat_ui passes it,
falling back to the seeded file only when no login has happened. The commit
that introduced the retirement said the replacement was "never returned or
stored: nothing needs it again". That was wrong, and the docstring now says so
and says why.
Studio.password is a credential held for the run, unlike anything else in that
module, so the module docstring records it and assert_chat_ui adds it to the
scrub set before it can reach a log.
test_the_session_remembers_which_password_is_current pins both directions: the
retired session reports the replacement it minted, and a session that never had
to change reports the password it logged in with.
Still failing, and NOT downstream of the 403 after all: gguf_export reports
install_kind=None on a run where inference, tool calling and training all
passed, so no llama.cpp is installed under STUDIO_HOME regardless of the
auth gate. I said last time that this might resolve itself once the 403 was
fixed. It did not. It is a separate gap and it stays open.
90 passed.
* Record what launch blocking did to the GRPO leg: it removed the fault
The UNWIRED note asked for one experiment: run the leg with
--cuda-launch-blocking so the illegal memory access reports at its real call
site instead of surfacing at torch.cuda.empty_cache(). Done, kernel
unsloth-t4-ci-b1f23e34, and the answer is not the one the note expected.
With blocking on there was no illegal memory access at all. engine_built true,
three steps, same 13.8GB peak, same versions to the digit. A fault that
disappears when the launches are serialised is a race, which is what one pass
in four already suggested and which no amount of re-running the leg will pin
down.
The run also exposed a SECOND problem, and it is separate from the first. It
failed on reward_std = [0.0, 0.0, 0.0] with grad_norm 0.0 at every step. The
completions in the report are coherent prose, so this is not the model
collapsing into degenerate output. It is two completions scoring identically.
The leg runs num_generations = 2 because that is what fits on a 14.56GB card,
and at two samples a tie on a coarse reward is ordinary rather than a bug. So
the leg's pass criterion is fragile at the size it has to be to fit, which is
worth knowing before anyone reads a red grpo leg as a regression.
The note now records both, says which question each answered, and states the
two that remain open: where the race is, and what pass criterion is honest at
num_generations = 2. The test gains the fourth session id and a check that the
note no longer reads as though launch blocking were still the next thing to
try -- a stale "try this next" is how an experiment gets run twice.
145 passed.
* Re-seed the account before the UI driver, because the two want opposite states
Third hardware run. The API assertions all pass now:
preflight, studio_ready, authenticate, gpu_inference, tool_calling,
lora_training -- all PASS
and the Playwright driver still fails, for a third and final reason in this
chain:
[ui] STEP change-password through UI (Setup your account)
[ui] change-password form attempt 1 failed: Timeout 60000ms exceeded
waiting for locator("#new-password"); page.url=.../login
The driver's first UI step drives the forced-change form. authenticate() has
to retire that same bootstrap password over the API to reach /api/inference/load
and /api/train/start at all. So by the time the driver runs, Studio shows an
ordinary login and #new-password does not exist.
That is not a value problem, it is a state problem, and the previous commit
only fixed the value. The API path wants the change DONE; the driver wants it
PENDING. One account cannot be in both states, and three runs walked the whole
chain to get here: 412345d2 failed the API assertions on the gate, 9ddd8ae4
fixed those and failed the driver on a stale password, this one fixed the
password and failed the driver on the form being gone.
assert_chat_ui now restarts Studio before handing over. start_server() removes
$STUDIO_HOME/auth, which is what re-seeds the bootstrap password, so the driver
gets exactly the state its first step expects. It is cheap here specifically:
this assertion runs last and stops the server anyway, so nothing after it needs
the API session, and the driver loads its own model through the UI.
test_the_ui_driver_gets_a_freshly_seeded_account pins the restart, that the
password is read AFTER it, and that the retired session's password is not what
gets handed over -- the restart is the whole fix, and a refactor that drops it
puts the payload straight back to a driver that cannot find the form.
Unchanged and still open: gguf_export reports install_kind=None. Three runs
now, one of them with inference, tool calling and training all green, so no
llama.cpp is installed under STUDIO_HOME and this is not downstream of the auth
gate.
91 passed.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Test the newest transformers and trl, not the newest zoo permits
The canary leg is documented as "the newest permitted library set" and that
is exactly what it installs: it resolves WITH unsloth_zoo in the resolution,
so zoo's own metadata is a ceiling. Measured against the two runs that
carried it:
canary transformers 5.5.0 trl 0.24.0 peft 0.20.0 accelerate 1.14.0
control transformers 5.5.0 trl 0.24.0 peft 0.19.1 accelerate 1.13.0
PyPI the same day: transformers 5.15.0, trl 1.9.2, peft 0.20.0, accelerate
1.14.0. The canary moved peft and accelerate to genuine latest and moved
transformers and trl not at all, which is what made it look like it was
working. zoo pins transformers <=5.5.0 and trl <=0.24.0, ten minors and a
whole major behind, so this CI could not detect a transformers 5.6+ or trl
1.x regression: it never installed one.
The new frontier leg upgrades transformers and trl with their dependencies
and without zoo in the resolution, which clears the cap because pip enforces
only the requirements of packages in the resolution. It runs the same SFT
payload as control and canary, so all three are directly comparable.
I expected it red. It is green. On a real T4: transformers 5.15.0, trl 1.9.2,
datasets 5.0.1, ten steps, canary emitted, and two fresh processes agreeing
bitwise. Unsloth trains and generates correctly a whole trl major above what
zoo's metadata permits.
Getting there took two kernels. The first used --no-deps plus a blanket
tokenizers upgrade and died before running anything, because an unbounded
upgrade overshoots the ceiling transformers declares:
tokenizers<=0.23.0,>=0.22.0 is required, but found tokenizers==0.23.1
safetensors>=0.8.0 is required, but found safetensors==0.7.0
A test pins that shape out, along with the zoo-in-the-resolution mistake that
would silently turn this into a second canary.
It goes on the second kernel's idle T4, so it costs no quota: a Kaggle
session bills wall clock once, not per card.
What it does NOT catch is recorded in the leg: the loss trajectory is not the
control's, and step 1 is computed before any update on identical weights,
data and seed, so 10.3222 against 6.4367 is a different loss function rather
than drift. Both converge. Which objective is intended is separate work.
* Install a CUDA llama.cpp before asserting the export used one
Four hardware runs reported llama_cpp_install_kind=None and failed the GGUF
export assertion for it. The reason was never subtle: nothing had ever
installed a llama.cpp under STUDIO_HOME, so the assertion was measuring an
absence rather than a selection. install_llama_prebuilt.py resolves a real
"linux-cuda" kind on an x64 CUDA host, so the bundle was available the whole
time and simply never fetched.
The install runs before the server, so the export route sees a llama.cpp that
was already there rather than one appearing underneath it, and it is its own
assertion because "the CUDA bundle would not install on this box" and "it
installed and the export against it failed" are different findings.
Its result is deliberately not checked by execute(). A box where the bundle
cannot be installed still runs inference, tool calling, training and the UI,
and still reports the export failing for the reason it actually failed.
Three tests. One of them CALLS install_llama_cpp rather than reading its
source, because run() in this payload already applies capture_output and text
and passing either again is a TypeError that no source-reading test would
catch -- which is how the first draft of this was written wrong.
* Record what install.sh left behind before replacing it
build_kernel.py runs install.sh --local, whose own comment says it puts a
llama.cpp on disk. Four runs still reported install_kind=None, so either it
never installed one or it installed something else. Fetching a CUDA bundle
fixes both cases and would hide which one it was, so the prior state is
recorded first and the original question stays answerable.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Look for llama.cpp where it is, not where the payload assumed
Five hardware runs reported llama_cpp_install_kind=None and failed the GGUF
export for it. There was no missing llama.cpp. install.sh --local installs one
into ~/.unsloth/llama.cpp, which is what install_llama_prebuilt.py's own
default resolves to, and this payload read STUDIO_HOME/llama.cpp, a path
nothing ever wrote to.
Adding an explicit install last commit is what proved it, because it recorded
what was there first. The installer selected the right bundle for a T4
accept ...linux-x64-cuda13-older.tar.gz coverage=75-89 supported=75,80,86,89
reject ...linux-x64-cuda13-newer.tar.gz reasons=missing_sms=75 out_of_range_sms=75
and then said
existing llama.cpp install already matches selected release
b10360-mix-87da1a2; skipping download and install
while install_kind_before and install_kind_after at the requested directory
were both None and the return code was 0. So a CUDA llama.cpp was present the
whole time, the sm_75 selection works, and only the assertion was wrong.
llama_cpp_marker checks STUDIO_HOME first, then the canonical location, and
returns None when neither has a marker so a real absence stays a real absence.
Against the exact situation the last run recorded, the old read answers None
and the new one answers linux-cuda.
Separately, and not fixed here: install_llama_prebuilt.py --install-dir X
exits 0 having written nothing to X whenever the canonical directory already
holds a matching release. A caller who names a directory and gets a success
with an empty directory has been told something untrue.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Read the field the marker has, not the one it never had
Six hardware runs reported llama_cpp_install_kind=None and failed the GGUF
export assertion for it. There was no missing llama.cpp and no wrong
directory. install_llama_prebuilt.py writes install_kind ONLY into the JSON
its resolver prints to stdout. The marker it writes to disk records
requested_tag tag release_tag published_repo asset force_cpu llama_backend
asset_sha256 source_* ggml_tree bundle_profile runtime_line coverage_class
install_fingerprint prebuilt_fallback_used installed_at_utc
and no install_kind at any point. So reading install_kind from the marker
answered None for every bundle on every box, including a working CUDA one,
and the assertion turned that into a failure. The last run had the installer
saying "existing llama.cpp install already matches selected release
b10360-mix-87da1a2; skipping download and install" while the payload reported
nothing installed.
runtime_line is the field that answers the question: cuda12 / cuda13 for the
CUDA bundles, and the backend name otherwise. It is written into the same
dict as the asset, so the two cannot disagree, and asset is kept as a fallback
for a marker too old to carry it.
is_cuda_install now matches the runtime line's shape rather than a fixed set
of names. A fixed set needs a new entry per CUDA major and fails CLOSED when
it lacks one, reporting a working cuda14 install as not-CUDA, which is the
same failure this commit is undoing.
The existing test wrote {"install_kind": "linux-cuda"} into a marker and
asserted it came back. That is how this survived: the test agreed with the
payload about a field neither read from reality, and stayed green through all
six runs. It is rewritten against a marker the installer actually produces.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Measure the load, not the unload it follows
The GGUF export probe reported device_vram_delta_mib: -1866.0 and was scored
as a load that never reached the GPU. It had not: a 3004 MiB chat model was
evicted inside the same window the 531 MB GGUF loaded in, because POST /load
with force unloads first and the baseline was sampled before the request.
Unload explicitly, wait for the reading to stop falling, then take the
baseline. Best-effort throughout, since the delta is evidence and not the
assertion: a refused unload or a missing nvidia-smi leaves the probe running.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Read the status fields the response actually declares
Run 8 shipped the unload-before-baseline fix and came back with
device_vram_delta_mib: -1866.0, byte-identical to run 7. The fix ran and did
nothing: it looked for model_path, model and active_model_name, and
InferenceStatusResponse carries none of those, so no model was ever
identified and no unload was ever sent.
Read model_identifier (documented as the loadable identifier, which is what
/unload's model_path wants), falling back to active_model and then to
loaded[0]. A new test parses the field names out of InferenceStatusResponse
and asserts the payload only reads names it declares, so the next guess fails
in the suite rather than forty minutes into a kernel.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Delete the Kaggle kernel when the launcher is killed, not only when it returns
release() is the budget control: a kernel left behind bills to its own
ceiling with nobody reading the result, and that ceiling has been observed
not to stop a wedged one. But it was reachable only from finish(), which runs
only on paths that RETURN. KeyboardInterrupt was explicitly re-raised past
it, and the default SIGTERM disposition exits without running atexit or
finally. So Ctrl-C, `kill`, and a cancelled GitHub Actions workflow all left
the kernel running. The file's own comment claimed the kernels were released
"on this path and on every other", which was true for returns and false for
signals.
Four paths, four covers:
normal return / handled error finish()
unhandled exception atexit
Ctrl-C, kill, Actions cancel SIGINT/SIGTERM/SIGHUP handlers
kill -9 the orphan sweep at the next launch
The signal handler re-raises through SIG_DFL so the exit status still reads
"killed by signal N"; exiting 0 would make a cancelled job look completed.
kill -9 cannot be caught, so a pushed slug is now recorded to
logs/kaggle_inflight.json the instant it exists, and every launch sweeps
entries whose owning pid is gone. Keyed on pid deliberately: an entry whose
owner is still alive belongs to a run in progress, and deleting it would
destroy a concurrent run and report the absence as a failure of the code
under test. A delete that fails keeps its entry rather than forgetting it.
The sweep runs before pushing, so reclaimed session slots are available to
the run doing the reclaiming.
Tests drive real subprocesses and real signals, since the property is about
process death. Verified failing against the previous behaviour: SIGINT,
SIGTERM and the unhandled-exception case all leave the kernel behind without
these handlers.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Only forget a Kaggle kernel once it is actually gone
kaggle kernels delete answers a transient rejection with a nonzero exit and
subprocess.run does not raise on that, so both cleanup paths recorded a
refused delete as done: sweep_orphans dropped the registry entry and
release_kernels set released=True, leaving a running kernel billing with
nothing left to reclaim it. Check the return code in both.
Two more holes in the same registry. result["kernels"] was published after
the whole push loop, so a SIGTERM once the first kernel was up found no list
and deleted nothing; it is now the same list object, assigned before the
loop. And --keep-kernel left an entry naming a pid that dies with the
launcher, so the next sweep called the retained kernel an orphan and deleted
exactly what the flag asked to keep.
* Stop the Studio leg passing on evidence it never had
Six ways this job could go green without measuring anything.
The evidence bundle was never unpacked: the launcher writes each kernel into
kaggle_evidence/<slug>/ and the workflow hands collect_evidence.py the
parent, so a top-level glob found nothing and every run reported that the
payload emitted no bundle. Recurse, the way extract_reports already does.
An install.sh regression read as infrastructure. sh() raised SystemExit, so
papermill stopped before any T4_SMOKE_REPORT existed, the launcher filed the
run as infra and the reporter exited 0 -- silently passing the installer
failures this workflow's path filter selects for. Failures of the checkout
under test now emit a failing report first; a missing GPU or a clone that
would not download keep the no-report infra path. A payload killed by the
driver deadline does the same, but only once the payload itself had started.
The process-level VRAM probe could never fire: InferenceStatusResponse
declares neither llama_server_pid nor pid, and FastAPI drops what the
response model does not declare, so the lookup was always None. Find the
llama-server processes in /proc instead.
Offload evidence outlived its load. offloaded_layers() takes the last match
in the whole log, so a reload that logged nothing inherited the chat model's
line and passed on evidence from a different model. Read each load forward
from a mark taken just before it.
A diverged run counted as a trained one. A T4 has no bf16, and an fp16 run
that goes NaN still reaches completed and still saves an adapter, while
trained_steps counted anything that was not None. Only finite losses count,
and non-finite ones are named as a failure.
An export slower than its HTTP timeout crashed the payload. The route blocks
for the whole export and the transport timeout is 900s against a 1200s
deadline, so the TimeoutError escaped the StudioError handler; it is now
treated as in-flight and the status poll decides.
Alongside: the restart that re-seeds the account no longer truncates the
backend log of every earlier assertion, the over-cap evidence fallback ships
the logs it says it ships rather than the report alone, the result line is
printed after the evidence is packaged so a packaging crash cannot publish a
pass, the training and export polls stop when Studio dies, Studio is launched
from the interpreter running the payload rather than whatever is on PATH,
the gate is told this leg pushes one kernel rather than the default two, and
the advertised opt-in label can start the workflow.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Name the encoding on every checked-in read, and survive a box with no nvidia-smi
tests/test_source_read_encoding.py fails on 33 read_text() calls in the two
Kaggle harnesses: unpinned text I/O on a repo file is cp1252 on a stock
Windows install and dies the moment that file gains a non-ASCII byte.
The second one is the payload's own. environment() runs inside finish(), on
every path out of the run, so a session Kaggle handed no driver raised
FileNotFoundError from gpu_inventory() and ended with a traceback and no
report at all. run() now answers a missing binary the way a failing one does,
and the preflight says there is no GPU.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Spend a Kaggle session on the opt-in label, not on every label after it
Two ways this leg spends or loses a GPU session it should not.
GitHub has no per-label filter on the pull_request event, so adding
`labeled` to the activity types starts the workflow for EVERY label added
to an affected pull request. The gate is handed the whole label list, so
once kaggle-studio-gpu-ci sits on the pull request it reads every one of
those events as forced, and a routine label change launches another
70-minute Kaggle run. Guard the gate job on the label that fired the
event; push and the other pull_request activities carry no
`github.event.label` and fall through unchanged.
The other way is the reverse: a run that should be red exits 0. The
dependency probe raises out of the cell when the Studio venv reports no
CUDA, and with no T4_SMOKE_REPORT the shared launcher files the run as
`infra`. install.sh --local resolving a CPU-only torch is exactly the
CUDA install regression this workflow's path filter selects for, and it
took that path. Ask nvidia-smi which case it is: a GPU the host can see
and the venv cannot use is a payload failure, and no GPU at all stays
infra.
* Treat a stalled kaggle push as transport, not as a red verdict
Every other Kaggle transport failure -- a 503, a refused push, a capacity
rejection -- returns a reason from push() and exits 0 as infra. A push that
stalls past the 600s ceiling did not: TimeoutExpired escaped launch.py, so the
process ended before finish() could write launch_result.json, the launch step
exited nonzero and GitHub marked the job red. The reporter that reads that file
and calls the run NOT RUN never got a say, and a red there says the code under
test failed when nothing ever reached a T4.
Catch it in push() and report push_timeout.
* Do not let a truncated copy of a chunk overwrite the complete one
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Name the encoding on every checked-in file the T4 harness reads
Repo tests (CPU) is red on this branch:
test_source_read_encoding.py::test_checked_in_file_reads_name_an_encoding
AssertionError: 17 file reads in the test trees touch a checked-in file
with the platform default encoding, so they break on Windows as soon as
that file gains a non-ASCII byte.
All 17 are in tests/kaggle/test_t4_smoke_harness.py, which this branch adds,
so this is ours and not inherited. The reads pull in the workflow YAML, the
smoke scripts, the committed reference JSON and ci-preempt.json, and none of
them said what encoding to read them as. On Linux that resolves to UTF-8 and
nobody notices; on a Windows runner it resolves to the ANSI code page and the
first non-ASCII byte in any of those files takes the test out with a
UnicodeDecodeError that has nothing to do with what the test is checking.
Every one now passes encoding = "utf-8", matching how the rest of the suite
spells it. Reads of files the test itself just wrote under tmp_path are left
alone: the gate only covers checked-in files, and those are ours end to end.
test_source_read_encoding.py 1 passed
tests/kaggle/test_t4_smoke_harness.py 148 passed
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Flatten Kaggle's kernel log before extracting the reports from it
Kaggle's kernels/output returns the log as a JSON array of {stream_name,
time, data} records, one record per line, so no line in the file starts
with T4_SMOKE_REPORT. extract_reports read it verbatim, so the log
fallback -- the one that covers a run whose executed notebook never came
back -- recovered nothing and a failed assertion was scored as infra with
the workflow exiting 0.
report.py::kernel_log_text and collect_evidence.py::iter_text already
flatten the records; extract_reports was the reader that did not. The
records are also cut by write rather than by line, so the join has to
happen before any line splitting.
* Keep the slug a timed out kaggle push may have created
A push whose CLI stalls past the 600s ceiling says nothing about whether
Kaggle took it. Kaggle can accept the push and start the kernel billing
before the response is lost, and the branch returned infra while keeping
neither the slug nor an inflight entry, so finish() had nothing to delete
and the next launcher's orphan sweep had nothing to see. The kernel ran
to its ceiling with nobody reading the result.
The slug is ours and already decided at that point, so record it in the
inflight registry and hand it back as orphan_slug: release_kernels
deletes it on the way out, and a launcher killed first leaves the entry
for the next sweep. It is deliberately not returned as slug, because the
caller must not wait on a kernel that may never have been created.
* Align the Kaggle budget guide with the workflow it documents
The guide named --percent 15 and --reserve-hours 25 against a 30h account;
the workflow runs 40 and 20 against the 60h figure measured on 2026-08-11,
and it is the only consumer of that quota today. Following the guide would
have tuned the reserve from a baseline that no longer exists.
Re-derived from the same live numbers as the workflow header, and says which
of the two is the source of truth when they disagree.
* Stand the gate down on any unreadable kernel status
The concurrency verdict refused only when EVERY in-window status came back
an error. One unreadable status among readable ones was treated as an idle
account, and driving concurrency_verdict with
{busy: [], own: [], foreign: [], surveyed: 2, unreadable: 1, complete: True}
returned (True, ""). That kernel can be the human session the zero-foreign
policy exists to yield to, and proceeding takes the account's last slot: the
first push wins it, the second comes back at capacity, and the run reports a
subset of its legs.
A 404 is kept separate and stays benign. A deleted kernel is not an unknown
state and the launcher deletes every kernel it pushes, so blocking on that
would wedge the gate shut rather than make it careful.
* Close six gaps in the Kaggle T4 notebook CI workflow
- The pull_request trigger used GitHub's default activity types, which are
opened, synchronize and reopened only. The kaggle-t4-ci label the gate
advertises as a human override could therefore never start a run on its
own. Subscribe to labeled as well.
- pyproject.toml was in neither paths filter, though every payload installs
the commit under test as a distribution built from it. BUDGET.md counted
it as a watched path already.
- The job deadline of 120 minutes was shorter than the launcher's worst
case. Two sequential pushes can take 2 x (4 x 600s + 315s of backoff) and
the 5400s polling window only starts after them, so about 187 minutes with
the deletions; a runner killed at 120 takes finish() -> release() with it
and leaves pushed kernels billing quota. 200, with the arithmetic written
down beside it.
- The gate surveys quota and in-flight kernels before t4-smoke queues on the
account-wide concurrency group, so a second sampled run could launch on an
approval a full run old. Re-ask with the slot in hand, immediately before
the push; --force true skips the sampling draw and nothing else.
- --zoo-ref main let each payload's own pip resolve a different unsloth-zoo
commit, including between the control and the canary within one session,
and zoo is not in pins/control.txt either. Resolve it once with git
ls-remote and pass that SHA to every leg.
- A dispatch with max_steps != 10 still shipped the 10-step reference, and
check_reference reports step_count_mismatch as a failure, so the run could
only ever go red on arithmetic. Drop the band automatically when the counts
differ, and say so.
Also: check out the same head SHA the launcher installs, so the harness and
the package under test are one snapshot rather than the merge tree and the
head; and run tests/kaggle/test_t4_smoke_harness.py on the runner before
anything is built or pushed, since testpaths limits pytest to tests/security
and nothing else collected it.
* Read back what the Kaggle kernel actually reported
Two ways a real payload verdict was being lost in transport, both ending in
verdict=infra and a green workflow.
The kernel log fallback could never work. Kaggle's kernels/output hands the
log over as a JSON array of {stream_name, time, data} records, which is why
report.kernel_log_text flattens it before reading; extract_reports scanned
the file as text, so no line began with the report prefix and the fallback
that exists for the run whose notebook never came back found nothing. Record
boundaries are not line boundaries either, so the records are joined before
the scan rather than read one at a time.
push() documented a fresh slug per attempt and used one for all four. Pushing
to an id that already exists does not replace it: Kaggle files a new version
and starts a second batch session, and kernels status and kernels/output send
no version label, so they answer for the newest session only. A retry after a
lost response therefore collected the wrong execution's evidence while the
first kept a session slot and billed unseen. Each attempt now takes its own
slug and discards the previous one first, and every slug filed is returned
and recorded, since a push that reported an error may still have landed.
* Give each payload its own everything, and report a probe failure
Four ways the two payloads of one kernel were not actually isolated, plus
the import probe exiting without a verdict.
The probe was the P1. When the commit under test breaks import unsloth, the
verify cell raises before the run cell can write a report, the launcher
extracts none, and no reports is classified as infra -- so a deterministic
import regression exits green. A probe failure is a verdict, not missing
evidence, so it now emits its own failing report.
nvidia-smi failing or a one-GPU allocation fell back to N_GPU = 1 and pinned
both payloads to device 0. Each child still saw exactly one card and passed
its own visibility assertion, so the contention came back looking like a code
failure. A shortfall is infrastructure and stands the kernel down before a
thread starts, which is what the comment above it already claimed.
A payload whose virtualenv could not be built kept the base python3 kernel
and installed into the shared system site-packages. That is the one thing the
per-child venv exists to prevent: the legs install deliberately different
library sets, so the last writer wins and the resulting import error reads as
a regression. The payload is skipped instead.
Both payloads materialised into one source directory and compiled into one
unsloth_compiled_cache. The sources are byte-identical copies but write_bytes
truncates first, and the compile cache is a relative path resolved against a
working directory both papermill children inherit while compiling the same
modules against different transformers versions. Each payload now owns both,
and the prune globs still reach them.
Finally, --smoke-args is appended after every leg's own arguments, so the
workflow's --max-steps 10 landed after the gpt-oss leg's --max-steps 3 and
argparse took the last one. The 20B leg was training for ten steps rather
than the three that were measured to fit. A shared argument no longer reaches
a leg that sets that option itself.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
---------
Co-authored-by: danielhanchen <unslothshared@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
|
||
|
|
bb11b371e7
|
Run deterministic notebook smoke tests on real Kaggle T4s (#8440)
* Run deterministic notebook smoke tests on real Kaggle T4s
The notebooks are written for a T4: sm_75, fp16 with a gradient scaler,
xformers rather than flash-attention, 16GB. Nothing in CI runs on that
card, so a regression that only appears there reaches users first.
Adds a gated job that launches one Kaggle GPU session and runs the smoke
payload once per T4. The account has a 60h weekly budget shared with
other consumers, so the job is a deliberate minority consumer of it:
paths filters (only 9.9% of a week's 444 main commits touch unsloth/),
a ~10% sampling draw keyed on the run id so a re-run cannot reroll, a
quota floor read live from Kaggle's own API, and a stand-down when the
account already has a kernel in flight. Expected cost is 5-7 GPU-hours
a week.
Only a payload that ran on a T4 and disagreed with its assertions turns
the check red. Throttling, the 2-kernel concurrency cap, a dead session
or a download that would not complete are all warnings, because a free
external service blocking merges would be ignored within a week.
The payload asserts three things, worth different amounts. Two fresh
processes must agree bitwise on per-step loss and grad_norm; that one is
exact, and it is fresh processes because two in-process cycles disagreed
from the first step while two processes agreed on all ten. Greedy
decoding must emit the canary string exactly, which catches trained
weights never reaching the generate call. Metrics must sit inside a band
around a committed reference, which is a tolerance and never an equality,
since fp16 reduction order moves with the driver and the libraries.
Ten steps rather than three: under fp16 the scaler skips the first two
overflowing steps, so a three-step run lands one real update and the
canary never forms. The dataset is supplied as prompt/completion columns
so the loss falls only on the answer.
* Make the payload cells the T4 rejected actually run there
Two launches on real Kaggle T4s died in the scaffolding before a training
step ran, both of them in generated code that nothing had ever parsed.
The dependency probe imported unsloth_zoo before unsloth. Zoo's __init__
ends with a find_spec("unsloth") guard and raised "Please install Unsloth
via pip install unsloth" on a session where unsloth was installed and
imported cleanly one entry later, so the payload aborted over a dependency
that was not missing. Probe unsloth first, and invalidate the import caches
after the pip installs that this interpreter did not see at startup.
The reference argument was generated as a shell fragment and spliced into
the middle of a Python list literal, with doubled braces that left ROOT
unsubstituted. The cell was a SyntaxError, and the workflow passes
--reference on every path, so no payload could ever have reported.
Verified locally by executing the regenerated run cell end to end: two
fresh processes, bitwise identical metrics, canary exact.
* Bound the in-flight kernel survey by session length, not kernel count
The concurrency check status-checked the twelve most recently run kernels,
which is a sample rather than a search: a kernel that started three hours
ago and is still running is missed the moment twelve newer ones have since
run, and the push then fails at the account capacity cap and is reported as
infra.
Kaggle kills a notebook session at twelve hours, and the listing is sorted
by last run time, which for an unfinished kernel is when it started
(measured: a kernel pushed at 10:05:19Z lists as 10:05:19.297). So walking
until the first entry older than that ceiling covers every kernel that can
still be in flight, and stopping there is exhaustive rather than arbitrary.
An hour of slack is added for clock skew.
Two silent ways to read an unanswerable question as a green light are
closed with it. Hitting the page cap now marks the survey incomplete, and
a survey where no status could be read at all is unknown rather than idle.
Both stand down, which is the cheap direction: the next commit draws again
in minutes.
On the live account this now costs three status calls instead of twelve.
* Stop the reference band check from passing NaN unconditionally
The band comparison did its NaN handling by accident, and it got it wrong.
Under fp16 the gradient scaler logs a NaN grad_norm on every step it skips,
so a committed T4 reference genuinely contains NaN. Left to the arithmetic,
abs(x - NaN) is NaN and NaN > tolerance is False, so those steps passed
whatever they held, including the case the check exists for: a step that
used to overflow and no longer does. Compare NaN to NaN as equal and NaN
against a number as a deviation, and treat a field logged on one side only
as a deviation too, since no tolerance covers a change in shape.
The mapping from an out-of-band verdict to the failure list moves into
reference_failures() so the path that turns the job red can be tested
without a GPU.
The references README now says plainly that no reference is committed, that
one must not be borrowed from other hardware, and how to lift one out of a
green run's evidence rather than spending a session on it. It also records
a measurement: the absolute floor never engages on this trajectory. The
smallest value on the ten-step curve is a loss of 0.1428 against a floor of
0.05, so the floor changes no verdict today and is kept for a configuration
that does go lower.
* Name the cause when a kernel finishes and reports nothing
A kernel that ends COMPLETE with no payload report is the hardest outcome
to read: the summary has no metrics to show and the reason sits in an
artifact nobody downloads. It is also the outcome both real T4 failures
took.
Kaggle returns the kernel log as a JSON array of stream records rather than
as text, so the interesting line arrives split across dozens of them and
reading the file directly shows a wall of JSON. Flatten it, filter to the
driver and payload sentinels plus tracebacks, and fold the tail into the
job summary. Checked against the evidence of a real failed run: the
SyntaxError that killed it is now visible without leaving the summary.
* Cover the four T4 CI fixes with CPU-only tests
The generated cells are now compiled on both the with-reference and
without-reference paths, which is what would have caught a SyntaxError that
instead cost a Kaggle session, and the dependency probe's module order is
asserted.
The in-flight survey gets a fake Kaggle client and the case that motivated
it: one kernel still running behind forty newer finished ones, which a
count-bounded scan misses and a time-bounded scan cannot. Also the window
boundary, timezone-aware and missing timestamps, the page cap, and the
difference between some statuses unreadable and all of them.
The band check is now proved to fail rather than assumed to work: a
perturbation half a band-width past the limit, a moved scaler skip pattern
in both directions, a field that stopped being logged, and a length
mismatch, each asserted through to the failure list. Three tests perturb
the committed reference itself and skip, saying so, until one exists.
41 passed, 3 skipped.
* Parse every generated cell on every build path before spending a session
Two of the three Kaggle sessions spent so far died on generated code that
nothing had ever parsed. The compile check now builds all three paths the
generator has, using the workflow's own argument list verbatim, including
the one where a committed reference exists and is carried inline as a
fourth file: that path becomes live the moment a green run supplies the
file, and would otherwise first be exercised on Kaggle.
Parsing alone is not enough. A template hole that substitutes to a bare
identifier parses and then NameErrors, which costs the same session, so
every cell is also checked in execution order for names nothing before it
defines. Both checks were confirmed to go red: the first on the exact
shell-fragment-in-a-list-literal defect that cost session three, the
second on a run cell reading a name no cell binds.
The carried sources are also decoded the way the kernel will decode them
and compared byte for byte with the repo, so the T4 cannot quietly run
something other than what is committed.
* Commit the T4 reference the first green Kaggle run produced
Kernel danielhanchen/unsloth-t4-ci-e3c6661f, terminal state COMPLETE, both
payloads passing on their own Tesla T4 of one session. Two fresh processes
agreed bitwise on all ten steps on both cards, max_abs_diff exactly 0.0 for
loss and for grad_norm, and the two cards independently produced the same
ten values as each other, so four processes agree rather than two. All four
cycles emitted the canary exactly. The scaler skipped steps 1 to 3 every
time, which is the behaviour the ten-step length exists to accommodate.
The file is reports[0] of that run, copied whole by the recipe the README
already documented, and nothing in it is from other hardware.
One assumption the run contradicted, now recorded rather than repeated: the
session had no xformers. unsloth installs --no-deps, unsloth_zoo does not
carry xformers and the Kaggle image does not either, so the banner read
Xformers = None and this trace is the fallback attention path. Adding
xformers to the install would move these numbers and require a recapture.
The floor's smallest observed value moves from 0.1428 to 0.0871, still
above the 0.05 denominator floor, so the floor stays inert and the test
that re-derives that keeps passing.
The three tests that perturb the committed reference now run instead of
skipping. 45 passed, 0 skipped.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Name the in-flight tolerance the Kaggle gate stands down on
The gate declines to launch whenever any kernel of the account is in
flight, which is stricter than Kaggle's 2-kernel cap requires. That is
the intended policy, so it is now a named constant with the tradeoff
written next to it rather than an implicit property of the code, and a
--allow-in-flight argument that cannot be raised to the cap itself.
Default behaviour is unchanged.
Also files the workflow under 'never' in ci-preempt.json: cancelling a
run does not stop the Kaggle kernel it already pushed, and the orphan
then bills quota to its own ceiling with nobody watching.
* Run three steps, and refuse a reference captured at another count
max_steps drops from 10 to 3. Measured consequence: under fp16 the
gradient scaler starts at 65536 and skips every step it overflows on,
and the committed reference shows steps 1, 2 and 3 all skipped, so a
3-step run of the old configuration applied zero optimizer updates.
--init-loss-scale pins the scaler below the overflow point so those
three steps are real updates, and optimisation_failures() fails a run
whose every step was skipped rather than letting it report as training.
The committed reference was captured at 10 steps and no longer
describes the run. check_reference now reads the max_steps the
reference records and refuses to compare across counts, as a failure
with both counts named -- including when the reference does not say
what it was captured at. A quiet pass there would be a green check
comparing a run against a curve it has nothing to do with.
The reference is therefore stale until one recapture run. The workflow
gains skip_reference_band for exactly that dispatch, and the recipe is
in references/README.md.
Also brings the workflow onto the repo's conventions: pinned action
SHAs matching the rest of CI, a workflow-level concurrency group that
deliberately does not cancel in progress, a fork guard on the gate
job, typed dispatch inputs and an artifact name without the run id.
* Keep the run at ten steps, and say why three does not work
The committed reference is the evidence: the fp16 scaler reports grad_norm
NaN at steps 1, 2 and 3 and a finite value from step 4. It starts at 65536,
halves on each overflow, and skips the step it overflowed on, so a 3-step
run applies zero optimizer updates. Loss stays around 10, the canary never
forms, and the band would compare three points of a curve that never moved.
Shortening also saves nothing worth having: a launch costs about 0.08h and
that is almost all pip install, not training.
The step-count guard, the all-steps-skipped guard and --init-loss-scale all
stay, since they are what makes a shorter run possible later and what stops
a vacuous one reporting green. The pin is off by default so the committed
reference keeps applying.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Turn one T4 payload into four, and pair a pinned control with a canary
The workflow ran a single tiny SFT payload on both cards of one Kaggle
session and spent about 1.4 GPU-h a week doing it. The budget is now 40,
and the question asked of it is wider: catch regressions in trl,
transformers, accelerate, peft, bitsandbytes, torch and vllm, and catch
torch.compile falling over on sm_75.
Four legs now, two kernels of two T4s each, described once in
.github/scripts/kaggle_t4_ci/legs.py rather than restated in YAML:
control the existing tiny Qwen2.5-0.5B determinism run, pinned
canary the same run on the newest library set Unsloth's own
constraints allow
gptoss gpt-oss-20b LoRA, for torch.compile and the forced float32 path
grpo Qwen3-4B GRPO through a vLLM engine on the same card
control and canary are one instrument rather than two tests. Same payload,
same seed, same data, same step count, on the two cards of the same
session; the only difference between them is the installed versions. A red
canary beside a green control is a library release, and the job summary
already puts the two version sets side by side so the bisect is a diff
rather than an investigation. The reference band applies to the control
alone: two library sets do not produce one fp16 trajectory, so the canary
asserts what does not depend on versions instead.
The pins are evidence rather than preference. transformers and trl are the
pair the committed reference was captured with; peft, accelerate and
bitsandbytes come from probe kernels 8161ceb9 and 7ab727f1, two independent
T4 sessions that ran the identical base install and agreed. torch stays
unpinned and recorded, because it comes from the Kaggle image and replacing
it is the failure mode the grpo leg spent two probes on.
The gate now separates this workflow's own kernels from everybody else's.
It takes both of Kaggle's concurrency slots, and only when the account is
otherwise idle: one kernel belonging to anyone else still stands it down
entirely, which is the same policy as before stated at the right grain.
* Leave the vLLM leg unwired, and stop trusting Kaggle's own kernel timeout
Three probe sessions tried to run the Qwen3-4B GRPO leg on a T4 and none of
them reached a training step. None of them failed for a reason to do with
sm_75, memory or GRPO either: vllm==0.11.2 pins torch==2.9.0, so the leg has
to replace the Kaggle image's torch, and that is what killed all three.
With the image's site-packages visible, pip treats torch's pinned NVIDIA
runtime packages as already satisfied by the copies belonging to 2.10, and
the result is a torch that installs cleanly and cannot be imported --
libcusparseLt.so.0 first, then ncclCommWindowRegister once that one was
named explicitly, identically on vllm 0.11.2 and 0.15.1. An isolated venv
resolving the whole stack got further and then the session wedged.
So the leg keeps its payload, its assertions and its install, and moves to
legs.UNWIRED with the three kernel slugs and what each one measured. Wiring
it now would make the check permanently red and spend the budget doing it.
The second kernel keeps its spare T4, which costs nothing, and that is where
the leg goes when the install works.
The third probe also cost about an hour of quota teaching us that the
push-time kernel timeout is not a budget control. Pushed with -t 5400, its
own nbconvert crashed at t=406s and the session sat in RUNNING for over two
hours -- past that ceiling and past the launcher's own deadline, because one
status call had blocked with no socket timeout to bound it. Deleting the
kernel by hand took the account's used-hours figure back down, which is the
measurement that settles which control is real. The launcher now sets a
socket timeout and deletes every kernel it pushed on every path out, and
the workflow header says which of the three bounds is load-bearing.
Sampling recomputed on the payload that actually ships: 40%, from measured
per-kernel cost rather than the old estimate, with the note that wiring a
fourth leg means recomputing it rather than editing one line.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Re-solve the GRPO leg's install so it stops replacing the image's torch
Three probe sessions never reached a training step, and all three died the
same way: vllm==0.11.2 pins torch==2.9.0, so the leg had to replace Kaggle's
torch 2.10.0+cu128, and pip treats the image's NVIDIA runtime packages --
which belong to 2.10 -- as satisfying the new torch's pins. That imports as
libcusparseLt.so.0 missing, then as libtorch_cuda.so undefined symbol
ncclCommWindowRegister one package along, then as an isolated venv that spent
an hour of quota resolving a CUDA stack and never produced payload output.
None of that is a question about sm_75, memory or GRPO. It is one line: no
vLLM release in the 0.11-0.16 range pins the torch the image ships. Releases
0.17.0 through 0.19.1 pin torch==2.10.0 exactly, so pin 0.19.1 and the leg
replaces nothing, keeps system_site_packages, and installs like any other.
Drop xformers with it. Its vLLM attention backend was deleted in 0.12.0, so
it would be a package nothing selects. sm_75 has no FlashAttention and no
FlashInfer, and the ladder in vllm/platforms/cuda.py falls through both to
TRITON_ATTN; the leg names that in VLLM_ATTENTION_BACKEND so a release that
reorders it goes red here rather than quietly selecting something else. 7.5
is still in CUDA_SUPPORTED_ARCHS at v0.19.1 and fp16 is a supported dtype
below capability 8.0.
Still unwired. Two things are static evidence rather than a run: whether
0.19.1 starts on sm_75 at all, and whether 8GB of 16-bit weights plus an
engine plus a LoRA trainer fit in 14.56GB. UNWIRED says so in those words,
and a test asserts the note still names the pin and still says what is
unknown, so nobody wires this off prose that outlived its measurement.
Also ports the vacuous-test fix from the stacked Studio branch: pre-commit.ci
reformatting default=10 to default = 10 stopped the payload regex matching,
so the step-count agreement check was comparing nothing.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Stop the GRPO leg JIT-compiling flashinfer on a machine that cannot link it
First probe on the re-solved install, kernel unsloth-t4-ci-e2d9ce9b. The
install works: torch stayed at the image's 2.10.0+cu128, vllm 0.19.1
installed and imported, xformers was absent as intended, and the payload ran
on a real Tesla T4 reporting capability 7.5 with TRITON_ATTN accepted. It
reached engine construction, which is further than any earlier probe got by
the whole width of the install.
It died in flashinfer 0.6.6's JIT, and not for an sm_75 reason. All three .cu
files compiled cleanly for -gencode=arch=compute_75,code=sm_75. The LINK
failed:
/usr/bin/ld: cannot find -lcuda
-L/usr/local/cuda/lib64/stubs is already on the command line, so the image
has no driver stub libcuda.so, only the runtime libcuda.so.1. Nothing about
that is fixable from a payload.
Set VLLM_USE_FLASHINFER_SAMPLER=0 so nothing JITs. The sampler has a native
path, and skipping the build also saves a four-file nvcc compile inside a
session billed by wall clock. UNWIRED records what the probe measured and
narrows what is left to one question: whether the engine builds now.
* Make -lcuda resolvable so flashinfer can link what it compiled
Second probe, kernel unsloth-t4-ci-916d5986, failed identically to the first
with VLLM_USE_FLASHINFER_SAMPLER=0 set. That is the informative part: the
JIT is not reached only through the sampler, so switching off one consumer is
whack-a-mole.
The failure was never sm_75. Both sessions compiled all three .cu files
cleanly for -gencode=arch=compute_75,code=sm_75 and died at the link:
/usr/bin/ld: cannot find -lcuda
-L/usr/local/cuda/lib64/stubs is already on the command line; the image ships
no libcuda.so at all, only the versioned libcuda.so.1, and the linker will
not resolve -lcuda against a soname. Normally the toolkit's driver stub fills
that gap; this image has the directory and not the file.
So symlink libcuda.so at the real driver in a scratch dir and put it on
LIBRARY_PATH, which gcc and ld search for -l. No root, nothing written into
/usr/local, and it fixes every flashinfer op rather than one. Linking against
the real driver instead of a stub is correct here: the driver is present,
which is the whole reason a stub would have substituted for it.
Also widen the captured traceback to head AND tail. The last probe's
6000-char tail was entirely ninja's own output, so the Python frames naming
the caller were exactly what got dropped.
Probe 2 also measured the fit question the notebook raised: peak reserved
7.96GB of 14.56 with load_in_4bit false, so 16-bit weights are not what is
short of room here.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Only count a libcuda the linker will actually search for
Kernel unsloth-t4-ci-d0d480b6: the shim reported already_linkable and did
nothing, and the link failed anyway. It had found libcuda.so in
/usr/local/cuda/compat -- which is real, and which nothing passes with -L.
The failing ninja line names /usr/local/cuda/lib64 and
/usr/local/cuda/lib64/stubs, and those are the only two directories that can
answer -lcuda.
So the precondition now checks exactly those two, and compat becomes what it
is actually good for: a symlink TARGET when ldconfig and ctypes both come up
empty. A library the linker will not search for is not a library the linker
can find.
Two tests: one reproduces the compat-only machine and asserts the shim is
built anyway, one pins the searched directory list, since widening it is
precisely how the check went wrong.
* Give the base model a chat template, and record what the link shim did
The link shim works. Kernel unsloth-t4-ci-27b0dc2e reported
libcuda_shim: needed true, applied true,
searched [/usr/local/cuda/lib64, /usr/local/cuda/lib64/stubs],
real /usr/local/cuda/compat/libcuda.so
and flashinfer is gone from the failure entirely. Memory went from 7.96GB to
11.36GB of 14.56, which is the vLLM engine actually loading, and the run
reached trainer.train() -> _run_epoch -> training_step. Both questions
legs.UNWIRED was holding this leg for -- does vLLM start on sm_75, does it
fit in 14.56GB -- are answered by that.
It then failed on the payload's own gap:
ValueError: Cannot use chat template functions because
tokenizer.chat_template is not set
unsloth/Qwen3-4B-Base is a base model and ships none. The notebook this leg
comes from solves it with an SFT priming stage that installs a template
before GRPO starts; this leg has no priming stage, so it sets a minimal
ChatML template directly and records in the report which of the two worlds
the run was in.
The base model stays. GRPO on an instruct model would measure the instruct
tuning as much as the run, and the payload's rewards are format-and-digit
rewards a base model can move inside three steps.
A test renders the template rather than matching it as a string: a template
that does not render would trade a failure at step 1 for a failure at step 1
with a longer traceback.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Wire the GRPO leg: it runs on a T4 and the rewards move
Kernel unsloth-t4-ci-53efcc4e passed on a real Tesla T4. Step 2 logged
reward_std 0.707 and grad_norm 0.772, peak 13.60GB of 14.56GB, three steps
in 192s, and fast_generate returned coherent text. reward_std is the
criterion rather than loss, because TRL's GRPO loss is ~0 by construction at
num_iterations=1 and beta=0 and would pass on a run that learned nothing.
The config in args is the one that fit, not the notebook's. Two probes with
seq 2048, 4 generations, rank 32 and utilization 0.9 died in the backward at
unsloth_zoo/gradient_checkpointing.py:1013, peaking at 15.97GB in 16-bit and
19.25GB in 4-bit. 4-bit alone is not the lever: it peaked HIGHER, because
quantizing weights does nothing for activations while utilization 0.9 still
hands vLLM ~13GB up front, and UNSLOTH_VLLM_STANDBY returns the weights
during training but not the KV cache reservation.
An earlier probe reported this as 'CUDA error: an illegal memory access
was encountered' from empty_cache() inside vLLM standby. That was the async
shadow of the same OOM; CUDA_LAUNCH_BLOCKING=1 produced the honest error.
UNWIRED is now empty. The test that read its grpo entry is replaced by the
invariants that outlive any one leg: every leg is wired or explained, nothing
is both, and an unwired note says what is open. Two new tests pin the config
and the vLLM pin, so restoring either to a value that OOMs fails here rather
than on Kaggle.
* Add a CUDA_LAUNCH_BLOCKING switch to the GRPO payload
An illegal memory access is reported at whatever CUDA call synchronises
next, which on the first T4 GRPO run was empty_cache() inside vLLM standby,
nowhere near the kernel that faulted. Re-running with this flag produced the
honest error instead: torch.OutOfMemoryError in the backward. That is what
turned an unexplained CUDA fault into a memory budget, and the budget is now
the leg's config.
The environment variables are set before the first import torch in the
process rather than merely before train(), because they have to precede CUDA
context creation. It serialises every kernel launch, so it stays a switch and
never a default, and it is recorded in the report because it changes what a
timing means.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Write down where the Kaggle sampling percentages come from
The percentages in the two workflows were arithmetic nobody could check. This
records the measurements they came from, taken 2026-08-11 over a 28-day
window: 985 commits to main, 1157 PRs, and 8 of 60 sampled PRs touching the
paths a T4 training run could regress, so a 13% path-filter rate and roughly
75 candidate events per week.
Against 30 GPU-h/week for one account at ~0.75 GPU-h per launch, the ceiling
is about 33 launches per week if everything went to one workflow. The 15/5
split and the 25/10 reserve-hour floors follow from that, and the file says
what would change them: a second account, a wider path filter, or a session
cost above ~1 GPU-h.
* Give the GRPO leg its own kernel: pairing it with gpt-oss fails
Kernel unsloth-t4-ci-70a2f4eb ran the wired pair on the two cards of one
session. gptoss passed: 32 unique graphs, 779 calls captured, 2 graph breaks,
peak 12.78GB. grpo failed with the illegal memory access again, at exactly
the 13.60GB peak at which it PASSED alone on kernel unsloth-t4-ci-53efcc4e,
same config to the flag.
Same peak, different outcome, so the constraint is not GPU memory: the driver
pins one card per payload. gpt-oss offloads to host RAM and vLLM wants host
RAM, and a Kaggle session has one host.
A third kernel costs a queue slot rather than quota, since Kaggle runs two at
a time and bills wall clock per session. That is cheaper than a leg which
fails half the time for a reason the report cannot show.
Two tests pin the rule in both directions: grpo must not share a kernel with
gptoss, and control must still share one with canary, because those two are a
matched pair whose comparison is the whole instrument.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Unwire the GRPO leg: running it alone reproduced the failure I blamed on pairing
I gave grpo a kernel of its own earlier today on the reasoning that sharing a
session with gpt-oss was what broke it. It had failed paired
(unsloth-t4-ci-70a2f4eb) and passed alone (unsloth-t4-ci-53efcc4e), so pairing
looked like the variable, and I wrote two tests pinning the separation.
That reasoning was wrong. Running the leg ALONE again
(unsloth-t4-ci-c98f14be, built from this branch with --legs grpo) reproduced
the paired failure exactly:
unsloth_zoo/vllm_utils.py:601 sleep() -> torch.cuda.empty_cache()
torch.AcceleratorError: CUDA error: an illegal memory access was encountered
engine_built false, peak 13.8GB/13.6GB of 14.56GB. The passing session and both
failing ones agree to the digit on every recorded version -- torch 2.10.0+cu128,
transformers 5.5.0, trl 0.24.0, peft 0.19.1, vllm 0.19.1, unsloth 2026.8.15,
zoo 2026.8.10 -- on the same peak, with UNSLOTH_VLLM_STANDBY=1 throughout. One
contrasting observation was never enough to blame a shared host.
So what three sessions show is an intermittent illegal memory access on Turing,
one pass in three, not a pairing effect. A leg that passes one session in three
cannot tell CI anything: it would go red for a reason no reader could act on.
grpo moves to UNWIRED with the three session ids and the open question written
down, rather than back into a kernel.
The note keeps what the leg already earned -- the vLLM/torch pin, the attention
backend, the libcuda shim, the chat template, the T4-sized config -- so
re-wiring means answering the IMA rather than redoing the install.
test_grpo_does_not_share_a_session_with_gptoss is replaced rather than deleted:
it asserted the opposite thing for the disproven reason, and would now pass
vacuously since grpo is in no kernel at all. The replacement pins that grpo
stays unwired and that the note still names all three sessions, so the evidence
cannot be dropped on the way back in.
145 passed.
* Record what launch blocking did to the GRPO leg: it removed the fault
The UNWIRED note asked for one experiment: run the leg with
--cuda-launch-blocking so the illegal memory access reports at its real call
site instead of surfacing at torch.cuda.empty_cache(). Done, kernel
unsloth-t4-ci-b1f23e34, and the answer is not the one the note expected.
With blocking on there was no illegal memory access at all. engine_built true,
three steps, same 13.8GB peak, same versions to the digit. A fault that
disappears when the launches are serialised is a race, which is what one pass
in four already suggested and which no amount of re-running the leg will pin
down.
The run also exposed a SECOND problem, and it is separate from the first. It
failed on reward_std = [0.0, 0.0, 0.0] with grad_norm 0.0 at every step. The
completions in the report are coherent prose, so this is not the model
collapsing into degenerate output. It is two completions scoring identically.
The leg runs num_generations = 2 because that is what fits on a 14.56GB card,
and at two samples a tie on a coarse reward is ordinary rather than a bug. So
the leg's pass criterion is fragile at the size it has to be to fit, which is
worth knowing before anyone reads a red grpo leg as a regression.
The note now records both, says which question each answered, and states the
two that remain open: where the race is, and what pass criterion is honest at
num_generations = 2. The test gains the fourth session id and a check that the
note no longer reads as though launch blocking were still the next thing to
try -- a stale "try this next" is how an experiment gets run twice.
145 passed.
* Test the newest transformers and trl, not the newest zoo permits
The canary leg is documented as "the newest permitted library set" and that
is exactly what it installs: it resolves WITH unsloth_zoo in the resolution,
so zoo's own metadata is a ceiling. Measured against the two runs that
carried it:
canary transformers 5.5.0 trl 0.24.0 peft 0.20.0 accelerate 1.14.0
control transformers 5.5.0 trl 0.24.0 peft 0.19.1 accelerate 1.13.0
PyPI the same day: transformers 5.15.0, trl 1.9.2, peft 0.20.0, accelerate
1.14.0. The canary moved peft and accelerate to genuine latest and moved
transformers and trl not at all, which is what made it look like it was
working. zoo pins transformers <=5.5.0 and trl <=0.24.0, ten minors and a
whole major behind, so this CI could not detect a transformers 5.6+ or trl
1.x regression: it never installed one.
The new frontier leg upgrades transformers and trl with their dependencies
and without zoo in the resolution, which clears the cap because pip enforces
only the requirements of packages in the resolution. It runs the same SFT
payload as control and canary, so all three are directly comparable.
I expected it red. It is green. On a real T4: transformers 5.15.0, trl 1.9.2,
datasets 5.0.1, ten steps, canary emitted, and two fresh processes agreeing
bitwise. Unsloth trains and generates correctly a whole trl major above what
zoo's metadata permits.
Getting there took two kernels. The first used --no-deps plus a blanket
tokenizers upgrade and died before running anything, because an unbounded
upgrade overshoots the ceiling transformers declares:
tokenizers<=0.23.0,>=0.22.0 is required, but found tokenizers==0.23.1
safetensors>=0.8.0 is required, but found safetensors==0.7.0
A test pins that shape out, along with the zoo-in-the-resolution mistake that
would silently turn this into a second canary.
It goes on the second kernel's idle T4, so it costs no quota: a Kaggle
session bills wall clock once, not per card.
What it does NOT catch is recorded in the leg: the loss trajectory is not the
control's, and step 1 is computed before any update on identical weights,
data and seed, so 10.3222 against 6.4367 is a different loss function rather
than drift. Both converge. Which objective is intended is separate work.
* Name the encoding on every checked-in file the T4 harness reads
Repo tests (CPU) is red on this branch:
test_source_read_encoding.py::test_checked_in_file_reads_name_an_encoding
AssertionError: 17 file reads in the test trees touch a checked-in file
with the platform default encoding, so they break on Windows as soon as
that file gains a non-ASCII byte.
All 17 are in tests/kaggle/test_t4_smoke_harness.py, which this branch adds,
so this is ours and not inherited. The reads pull in the workflow YAML, the
smoke scripts, the committed reference JSON and ci-preempt.json, and none of
them said what encoding to read them as. On Linux that resolves to UTF-8 and
nobody notices; on a Windows runner it resolves to the ANSI code page and the
first non-ASCII byte in any of those files takes the test out with a
UnicodeDecodeError that has nothing to do with what the test is checking.
Every one now passes encoding = "utf-8", matching how the rest of the suite
spells it. Reads of files the test itself just wrote under tmp_path are left
alone: the gate only covers checked-in files, and those are ours end to end.
test_source_read_encoding.py 1 passed
tests/kaggle/test_t4_smoke_harness.py 148 passed
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Align the Kaggle budget guide with the workflow it documents
The guide named --percent 15 and --reserve-hours 25 against a 30h account;
the workflow runs 40 and 20 against the 60h figure measured on 2026-08-11,
and it is the only consumer of that quota today. Following the guide would
have tuned the reserve from a baseline that no longer exists.
Re-derived from the same live numbers as the workflow header, and says which
of the two is the source of truth when they disagree.
* Stand the gate down on any unreadable kernel status
The concurrency verdict refused only when EVERY in-window status came back
an error. One unreadable status among readable ones was treated as an idle
account, and driving concurrency_verdict with
{busy: [], own: [], foreign: [], surveyed: 2, unreadable: 1, complete: True}
returned (True, ""). That kernel can be the human session the zero-foreign
policy exists to yield to, and proceeding takes the account's last slot: the
first push wins it, the second comes back at capacity, and the run reports a
subset of its legs.
A 404 is kept separate and stays benign. A deleted kernel is not an unknown
state and the launcher deletes every kernel it pushes, so blocking on that
would wedge the gate shut rather than make it careful.
* Close six gaps in the Kaggle T4 notebook CI workflow
- The pull_request trigger used GitHub's default activity types, which are
opened, synchronize and reopened only. The kaggle-t4-ci label the gate
advertises as a human override could therefore never start a run on its
own. Subscribe to labeled as well.
- pyproject.toml was in neither paths filter, though every payload installs
the commit under test as a distribution built from it. BUDGET.md counted
it as a watched path already.
- The job deadline of 120 minutes was shorter than the launcher's worst
case. Two sequential pushes can take 2 x (4 x 600s + 315s of backoff) and
the 5400s polling window only starts after them, so about 187 minutes with
the deletions; a runner killed at 120 takes finish() -> release() with it
and leaves pushed kernels billing quota. 200, with the arithmetic written
down beside it.
- The gate surveys quota and in-flight kernels before t4-smoke queues on the
account-wide concurrency group, so a second sampled run could launch on an
approval a full run old. Re-ask with the slot in hand, immediately before
the push; --force true skips the sampling draw and nothing else.
- --zoo-ref main let each payload's own pip resolve a different unsloth-zoo
commit, including between the control and the canary within one session,
and zoo is not in pins/control.txt either. Resolve it once with git
ls-remote and pass that SHA to every leg.
- A dispatch with max_steps != 10 still shipped the 10-step reference, and
check_reference reports step_count_mismatch as a failure, so the run could
only ever go red on arithmetic. Drop the band automatically when the counts
differ, and say so.
Also: check out the same head SHA the launcher installs, so the harness and
the package under test are one snapshot rather than the merge tree and the
head; and run tests/kaggle/test_t4_smoke_harness.py on the runner before
anything is built or pushed, since testpaths limits pytest to tests/security
and nothing else collected it.
* Read back what the Kaggle kernel actually reported
Two ways a real payload verdict was being lost in transport, both ending in
verdict=infra and a green workflow.
The kernel log fallback could never work. Kaggle's kernels/output hands the
log over as a JSON array of {stream_name, time, data} records, which is why
report.kernel_log_text flattens it before reading; extract_reports scanned
the file as text, so no line began with the report prefix and the fallback
that exists for the run whose notebook never came back found nothing. Record
boundaries are not line boundaries either, so the records are joined before
the scan rather than read one at a time.
push() documented a fresh slug per attempt and used one for all four. Pushing
to an id that already exists does not replace it: Kaggle files a new version
and starts a second batch session, and kernels status and kernels/output send
no version label, so they answer for the newest session only. A retry after a
lost response therefore collected the wrong execution's evidence while the
first kept a session slot and billed unseen. Each attempt now takes its own
slug and discards the previous one first, and every slug filed is returned
and recorded, since a push that reported an error may still have landed.
* Give each payload its own everything, and report a probe failure
Four ways the two payloads of one kernel were not actually isolated, plus
the import probe exiting without a verdict.
The probe was the P1. When the commit under test breaks import unsloth, the
verify cell raises before the run cell can write a report, the launcher
extracts none, and no reports is classified as infra -- so a deterministic
import regression exits green. A probe failure is a verdict, not missing
evidence, so it now emits its own failing report.
nvidia-smi failing or a one-GPU allocation fell back to N_GPU = 1 and pinned
both payloads to device 0. Each child still saw exactly one card and passed
its own visibility assertion, so the contention came back looking like a code
failure. A shortfall is infrastructure and stands the kernel down before a
thread starts, which is what the comment above it already claimed.
A payload whose virtualenv could not be built kept the base python3 kernel
and installed into the shared system site-packages. That is the one thing the
per-child venv exists to prevent: the legs install deliberately different
library sets, so the last writer wins and the resulting import error reads as
a regression. The payload is skipped instead.
Both payloads materialised into one source directory and compiled into one
unsloth_compiled_cache. The sources are byte-identical copies but write_bytes
truncates first, and the compile cache is a relative path resolved against a
working directory both papermill children inherit while compiling the same
modules against different transformers versions. Each payload now owns both,
and the prune globs still reach them.
Finally, --smoke-args is appended after every leg's own arguments, so the
workflow's --max-steps 10 landed after the gpt-oss leg's --max-steps 3 and
argparse took the last one. The 20B leg was training for ten steps rather
than the three that were measured to fit. A shared argument no longer reaches
a leg that sets that option itself.
* Count a moved step and a one-sided field as run-to-run differences
compare_metrics zipped the two traces positionally and skipped any field
that was not on both sides, so a grad_norm logged by one fresh process and
not by the other left identical true, and so did a step coordinate that
shifted, duplicated or reordered while the values stayed in place. Both are
nondeterminism, which is the one thing this comparison exists to find, and
check_reference already treats one-sided presence as a change in the shape
of what the trainer logged rather than as a skip.
* Assert the SFT leg's canary, adapter, repeats and reference properly
Six gaps, all of them a green run that established less than it claimed.
The canary was a substring test, so the exact, tolerance-free assertion the
docstring describes twice accepted '__UNSLOTH__!!!<anything>' -- which is
what a stopping or EOS regression produces. It is now an exact match after
stripping surrounding whitespace, with both readings recorded so a red says
which of the two it was.
The saved adapter was only ever checked for a filename while inference ran
on the in-memory model, and the caller-side reload the comment promised does
not exist anywhere. The serialized weights are now read back and checked for
tensors that are present, finite and not all zero; lora_B starts at zero, so
an all-zero file is an untrained adapter that would reload without
complaint.
--repeat 3 compared cycles 0 and 1 and threw the rest away. Every extra
cycle is now compared against the baseline, and the summary keys report.py
renders off the reproducibility block keep their shape.
optimisation_failures counted an infinite grad_norm as an applied update
because inf equals itself, so a run whose every step overflowed to inf
rather than NaN reported as a training run that trained. It requires a
finite norm now, which is what the loss check three lines above already did.
check_reference guarded max_steps out of a config block it was already
holding whole. The learning rate, the optimizer, the LoRA shape, the model
and the commit of the model repository that was actually read are compared
on the same terms, and so are the per-step coordinates, all of them refusing
before a single number is compared. Older references keep working: a key the
file does not carry is listed unchecked rather than called a mismatch.
A cycle that never wrote its report produced a failure report with no
environment block, which is the case where knowing which library set died
matters most. The fingerprint and the config are now read before the cycles
and carried into that report.
* Assert the float32 path, the training compile and the optimizer update
The gpt-oss leg exists for Unsloth's FORCE_FLOAT32 behaviour on a card with
no bf16, recorded the fp16/bf16/force-dtype state it ended up in on every
run, and asserted none of it. A run that quietly went through fp16 instead
logs finite losses, compiles and generates, and reports green while the one
thing this leg uniquely covers was never exercised. It is now a failure,
conditioned on the card rather than hardcoded to T4: where bf16 IS
supported the patch not firing is correct.
The compile assertion read Dynamo's process-global counters after training
and asked for at least one unique graph. Loading a 20B checkpoint through
Unsloth fills those counters long before trainer.train() is called, so a
training path that fell back to eager entirely still satisfied it. The
counters are now sampled before training too and the assertion is on the
delta.
grad_norm was captured in every metric entry and read by nothing. A run
whose gradients are all zero keeps a finite loss, compiles, and generates
text from the untrained base model, so the leg reported LoRA training it
had not done. At least one logged norm must now be finite and non-zero, and
as in the SFT leg the check declines to decide where grad_norm was never
logged at all.
* Generate through the trained adapter, and keep the engine verdict
The final fast_generate passed lora_request=None. fast_generate is the vLLM
engine's own generate and the trained LoRA reaches it only through
save_lora plus load_lora, so the check generated from the base weights and
passed whether or not the adapter could be transferred into the engine at
all -- which is the second of the two questions this probe exists to
answer. The request is built now, what happened is recorded, and a transfer
that failed is a failure.
engine_built was set on a local dict that train() only returned at the end,
so any exception after the engine was built threw the fact away and the
report recorded engine_built false. That is the opposite of what happened
and it erases the distinction the probe needs between vLLM being unable to
start on sm_75 and GRPO failing later. Facts are published into the report
as soon as they are known.
A reward on some steps satisfied the check that the docstring describes as
a reward on every step: a step whose reward functions never ran was
filtered out and the others covered for it. Every step that logged a loss
must now carry a reward; the summary row train() appends carries no loss
and is not a step.
Every logged grad_norm being NaN also passed. Under fp16 on this card every
step can overflow and be skipped while loss, reward and reward_std are all
still logged and base-model generation still returns text, so the length
check alone was satisfied by a run that updated nothing.
* Take the reference from the control report, not from reports[0]
extract_reports walks sorted(rglob(...)) over per-kernel directories named
after a random Kaggle slug, and inside the control/canary kernel
t4_canary_output.ipynb sorts before t4_control_output.ipynb. Driving the
real extractor over a four-leg run puts frontier first. Following the
recapture recipe as written therefore commits the canary, frontier or
gpt-oss trace as the control reference, and every later run then band-checks
against a curve it has nothing to do with. The recipe now selects the report
whose label is control and asserts there is exactly one, and carries the
model and the resolved checkpoint commit through with the config.
Also 45 CPU-only tests for what the payloads assert, which is the half of
the harness that test_t4_smoke_harness.py does not cover: it checks the
launcher, the gate and the generated notebook, and this checks whether a
given result dict is called a pass. All 45 fail on the parent of this
series apart from the ten that pin behaviour that was already right.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Let two runs that both overflowed on the same step agree
An fp16 overflow does not only log a NaN gradient norm. clip_grad_norm_
over a gradient holding an infinity returns infinity, so the same skipped
step can come back as inf on both cycles of the same configuration.
compare_metrics handled the NaN case explicitly and then subtracted
everything else, and abs(inf - inf) is NaN, which is not equal to 0.0. Two
bitwise identical traces were therefore reported as nondeterministic, and
the summary printed DIFFERED with a max_abs_diff of 0.0 beside it.
Equal values now short circuit before the subtraction. A one sided
infinity, and two of opposite sign, still reach it and still come out as
differences.
* Start the deletion deadline before the first push, not after the last
A Kaggle kernel bills from the moment the push is accepted, and the only
control that has been observed to stop one is the launcher deleting it.
That deletion was scheduled from a deadline started after every push had
returned, so the first kernel got --max-wait on top of however long the
second push spent in its retries: four attempts at the 600s subprocess
ceiling plus the backoffs is about 45 minutes, which turns a 90 minute
ceiling into 135 minutes of billing.
The deadline is now one invocation-wide clock started before the first
push, so time spent pushing comes out of the wait rather than being added
to it.
The reservation the gate makes was understated for the same reason. It was
derived from the ceiling passed to Kaggle at push time, which this workflow
already records as unreliable; it is now derived from the launcher's own
bound, one --max-wait per kernel, and the harness suite keeps the two in
step.
* Delete every slug a push filed, not only the accepted one
push() files a fresh slug per attempt and returns all of them, because a
push that reported an error may still have landed: Kaggle answers an
accepted push with a 5xx or a reset connection often enough to be a known
issue. Nothing then reconciled that list. Cleanup read entry["slug"], which
a failed push does not set at all, so two cases left a session running with
nobody watching it:
- the last attempt of a push that never succeeded, which is exactly the
ambiguous one that made the retry necessary;
- an earlier attempt of a push that later did succeed, since the discard
before each retry is best effort and subprocess.run does not raise on a
refused delete.
Both keep one of the account's two concurrency slots and bill GPU quota to
the kernel's own ceiling. Release now walks every slug the entry filed and
records which of them are gone. A delete for a slug Kaggle never created is
refused and costs one call, which is why this can be unconditional.
The job deadline arithmetic is recomputed for the extra deletions and for
the polling deadline no longer stacking on the pushes.
* Report an install that cannot be resolved as a failed payload
The install cell retried three times and then raised, emitting no payload
report. The launcher extracts reports and, finding none for that leg, calls
the run partial or infra and exits green -- so a commit whose distribution
cannot be resolved, or a control pin set that stopped resolving, passed the
one job that exists to test packaging metadata. Only the later verify cell
reported a failure, and the install cell never reaches it.
The exhausted branch now writes the same kind of structured report the
dependency probe writes, naming the group and the last pip error, before it
raises.
The retries are also backed off. They were immediate, so all three landed
inside the same upstream blip, and the third failure has to mean the
resolution is impossible rather than that one minute was bad.
* Keep the resolved versions in an import failure report
report.version_table builds the per leg comparison out of the report
dictionaries, not by scraping the kernel log, so a leg whose report carries
no versions is missing from the one table that answers the question the
control and canary pairing exists to answer: which release did the red leg
have that the green one did not. The versions were computed and printed one
line above the failure and then discarded.
They are now kept in a name, carried into the report, and the computation is
wrapped: a half installed distribution can make importlib.metadata raise,
and losing the report to a diagnostic would put the leg back to reporting
nothing at all.
* Stand the run down when the zoo commit cannot be resolved
The fallback to the branch name restored exactly what pinning the zoo
revision removed. unsloth-zoo is not in the control pin file, and every
payload pips independently on the kernel, so with main the control leg and
the version canary can resolve two different zoo commits inside one session.
That invalidates the control's reference band and the attribution the
pairing exists for, and the report records a distribution version rather
than a commit, so the drift is not visible after the fact either.
The ls-remote is retried three times, since one blip is not a verdict, and a
resolution that still cannot be made stands the run down: a warning, no
kernels pushed, and a green job, which is how every other infrastructure
stand-down in this workflow behaves. Nothing that spends a Kaggle session
runs after it.
* Run every CPU suite in tests/kaggle before spending a session
The step named one file, and pyproject.toml restricts default discovery to
tests/security, so the two suites added after it were collected by no
invocation anywhere. Measured at this commit: the step collected 159 tests
of the 227 in the directory. The 68 that ran nowhere are the transport and
payload assertion suites, which cover the report extraction, the payload
pass and fail rules, the reference checks and the per leg isolation.
It runs the directory now, so a suite added next to these is picked up
without this file being touched, and there is a test that collects whatever
the step names and asserts every suite in the directory is in the result.
Measured in a bare venv with pytest, transformers and CPU torch: 226 passed.
* Record the kernel a recaptured reference came from, not the leg label
The recipe wrote report["label"] into source_kernel, which is always the
string control. That field is the only thing in the file pointing outwards,
at the hardware execution its band was measured on, and it is what makes a
recapture auditable while the evidence artifact is still around. The
committed file carries a real slug, so following the recipe would have
replaced it with a word that names nothing.
The recipe now derives the slug from launch_result.json: each kernel
collects into a directory named after the last segment of its slug and each
leg's executed notebook is named after the leg, so the control leg's kernel
is identified rather than guessed at, and the recipe refuses if that does
not come out to exactly one.
The recipe is now executed by the test rather than pattern matched, against
a two kernel evidence tree.
* Pin the reference model, and say what the band did not compare
The committed reference carried no identity keys at all, so the checkpoint
pin added with the reference config never ran on any workflow invocation and
the band could return ok after the loaded revision moved. Two of those keys
are unknowable without another T4 session, but one is not: the control leg
passes no --model, so the reference belongs to DEFAULT_MODEL. It is recorded
now and that comparison is live.
resolved_checkpoint and resolved_revision stay unrecorded until the next
recapture, because inventing a commit hash for a run that happened is worse
than admitting the file does not carry one, and refusing on their absence
would turn every run red over a reference that is otherwise sound.
What changes is that the gap is no longer silent. A key present on one side
only is a pin that did not run: it is recorded under config_unchecked in
both directions now, including the one where the reference names a commit
and the run could not read one, and report.py puts that list on the job
summary next to the band verdict. A key neither side claims stays unreported,
since nothing was asserted about it.
* Record the count a fresh runner sees for the harness step
The comment beside the step names what a bare venv with pytest,
transformers and CPU torch collects, and it moved with the tests added
alongside these fixes. Measured, not estimated.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Record a push that times out instead of letting it kill the cleanup
subprocess.run(timeout=600) raises TimeoutExpired rather than returning a
failed push, and nothing caught it. Pushing the second notebook after the
first was accepted therefore left main() without finish()/release() and
without launch_result.json, so the accepted kernel billed to its own ceiling
with nobody reading it, and the slugs the timed-out attempt had filed -- the
most ambiguous ones there are -- were lost with the exception.
The timeout is now a failed attempt like any other: the slugs stay in
`attempts`, the retry discards the previous one, and release() reconciles
what is left. The whole of main() past authentication also runs under a guard
that still deletes and still writes the result on any unforeseen abort, since
nothing in the workflow cleans up after this script.
* Read the adapter itself, so a silent grad_norm cannot pass a leg
`if norms and not applied` passes on an EMPTY list, and the gptoss and grpo
legs had nothing else to fall back on: neither saves an adapter or reloads
one, so a Trainer or TRL change that stops logging grad_norm takes the only
evidence of an optimizer update with it and both legs go on reporting green.
Finite losses, captured graphs, rewards and non-empty generation are all
produced by the base model and the loader on their own.
Both legs now fingerprint their LoRA parameters before training and after,
and that reading decides it, with grad_norm as the fallback rather than the
source. Silence is still not evidence of nothing applied, which is the
position the earlier round took; it is now answered from the weights instead
of guessed at. Only when BOTH instruments are gone is the leg red, and then
for what it is: the training path it claims to cover can no longer be shown
to have run.
The tiny SFT leg is unchanged. It reads its saved adapter back off disk and
fails on an all-zero one, which is the same question already answered.
* Stand down on an unrelated label, and pin a dispatched ref to one commit
Two ways this workflow spends more than it budgeted for.
The `labeled` trigger fires for EVERY label, not just the opt-in one, so any
label applied to an eligible pull request started a run and a fresh sampling
draw. Once kaggle-t4-ci was present it was worse: it stays in the label list,
so every later label of any kind arrived as an override and forced a session.
The budget arithmetic counts pull request opens and pushes and no label
activity at all. The gate now takes the event action and the label that
arrived, and stands a `labeled` run down unless that label is the opt-in one.
Every other action is unaffected.
A dispatched unsloth_ref was forwarded to the payloads unchanged, and each
payload pip-installs on the kernel by itself, so a branch name could resolve
to a different commit in each of the four legs -- the control and the canary
compared across two different Unsloths, invisibly, since the report records a
distribution version rather than a commit. It is resolved once now, here,
exactly as the zoo revision below it is, and a ref that cannot be pinned
stands the run down rather than installing a moving branch. What a dispatch
varies is still only the package under test: the harness stays on the
checked-out tree, because most of it does not exist on older refs.
Label names and the dispatched ref travel through the environment rather than
being interpolated into the shell.
* Say in the budget file what a label event costs
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Close five ways a leg could pass on a run that trained nothing usable
Every one of these is a report that stays healthy while the thing the leg
uniquely covers did not happen.
- A LoRA weight that went NaN or infinite was the strongest possible pass:
the fingerprint returned a number, `NaN != finite` read as "the adapter
changed", and update_verdict said `applied` on exactly the no-telemetry
path it was written to decide. A non-finite reading is now its own
verdict, it beats a healthy grad_norm, and both call sites fail on
anything that is not `applied` rather than on the two verdicts they
happen to know about.
- The saved adapter was accepted on a nonzero tensor count, which lora_A
satisfies at initialisation. An adapter whose B matrices were all zero or
were dropped contributes exactly nothing and reloads as the base model,
so the B matrices are counted specifically and their absence is unusable
rather than fine.
- UNSLOTH_FORCE_FLOAT32 was tested for being nonempty. The loader writes
"0" on its ordinary branch before deciding whether to force, and every
production consumer reads `== "1"`, so forcing could stop firing with
fp16 and bf16 both still false and the leg green.
- The compile assertion fell back to the absolute graph count when no
pre-training baseline was read. That count is the loader's, so a training
path that ran entirely eager passed in the one case where training could
not be isolated. A missing baseline is now unverifiable.
- Reference drift divided through infinities to NaN, and NaN > tol is
False while max(worst, NaN) is worst, so a step that used to be finite
and now overflows was accepted without even moving worst_rel. Equal
signed infinities are the unchanged case; every other pairing is a
deviation, decided before the division.
* Report a payload that crashed, and re-emit its report on one line
The run cell wraps the payload in a child process and is the only thing
that sees the child's exit status. Two ways it threw that away:
A nonzero exit with no report printed "NO REPORT WRITTEN" and nothing
else. No report at all is `infra` at the launcher and one report of two is
`partial`, and both leave the workflow green, so a CUDA segfault, a native
abort or an OOM kill was accepted silently while the definitive exit
status was in hand. The crash is now emitted as a `passed: false` report
carrying the return code and a stderr tail.
The recovery path for a report whose own compact line fell out of the
retained stdout tail echoed the file verbatim. Every payload writes that
file indented and the launcher parses whole lines, so it was handed a lone
brace to decode and the definitive result was downgraded anyway. The file
is parsed and re-serialized compactly, and a file that will not parse
takes the crash path rather than disappearing.
* Delete the kernels for real, and prove the ref before paying for it
Four things this harness reported as done without ever establishing them.
Release: subprocess.run does not raise on a nonzero exit, so the cleanup
loop recorded every slug as released whatever Kaggle answered. The live
case is worse than a transient refusal. kaggle==1.7.4.5 has no "kernels
delete" subcommand at all (it landed in 1.7.5.0, Kaggle/kaggle-cli#762,
first released in 1.8.0), so argparse answered every delete with
"invalid choice: 'delete'" and exit 2, and the run still called the
kernel released while it billed on to its own ceiling. delete_kernel now
reads the exit code, retries a refusal, and a slug it cannot confirm gone
is named in the log, in launch_result.json and in a workflow annotation.
The pin goes to 2.2.4, because the version is load bearing and 1.7.4.5
could not do the job. Verified against the live API: 1.7.4.5's
authenticate() refuses KAGGLE_API_TOKEN, the only credential this
workflow has, and demands a kaggle.json nothing here writes; quota_view(),
which gate.py reads the remaining accelerator hours from, exists only on
2.x, so below it the call raised AttributeError into a handler that
recorded "quota unreadable" and let the run proceed.
Dispatched ref: a 40-character SHA was accepted on shape alone and
nothing asked whether unslothai/unsloth has that commit, so a mistyped
one pushed the paid kernels, failed every payload's git install, and
reported the pull request red for a commit that never existed. git
ls-remote cannot answer that (it matches refs and exits 0 with empty
output for any SHA); a git fetch of the object can, and it is the same
reachability pip needs. Applied to the ls-remote result too, not only to
the SHA the form supplied.
GPU probe: the verify cell's bare device_count assert aborted before the
run cell could emit its fallback report, so a CPU-only or
CUDA-incompatible torch wheel left the launcher with no report, which is
infra, which exits 0. It now emits passed: false the way the import probe
above it does, for any failure in the cell rather than the count alone.
gpt-oss: the forced-float32 assertion was conditioned on bf16_supported
is False, and main() records environment = {"error": ...} for the whole
probe when it raises. An unreadable card reading therefore skipped the
one check this leg uniquely carries while training, losses, adapter and
generation all still passed. Anything that is not a literal True or False
is now a failure, and the healthy floor fixture carries the reading so
every case below it exercises the check.
tests/kaggle: 292 passed.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Say the same things about this CI in fewer lines
Comment-only pass over the Kaggle T4 notebook CI: the prose blocks that
explain the leg pairing, the budget arithmetic, the cleanup contract and
the payload assertions were several paragraphs where a few lines carry
the same meaning.
Every measured fact stays: the kaggle client pin and the three things
1.7.4.5 cannot do, the ls-remote vs fetch behaviour on a bogus SHA, the
vLLM torch pin windows, the recorded losses, peaks, kernel ids and
version boundaries.
No code changes, verified by an AST comparison over every touched file.
* Count both deletion paths in the deadline that protects them
The job deadline exists so the runner is never killed during release(),
which is what deletes the kernels and stops the billing. Its arithmetic
counted one delete per filed slug and none of the discards, so it was
about half the launcher's real bound:
one delete DELETE_ATTEMPTS 3 x 180s plus backoffs 5 + 10 = 555s
push, per notebook 4 x 600s + backoffs + 3 discards = 4380s
two notebooks = 8760s
release(), 4 filed slugs per kernel = 4440s
evidence ~600s
total 13800s
That is 230 minutes against a 200 minute deadline, so a wedged run could
be killed mid-cleanup and orphan the kernels the deadline is there to
get deleted. The deadline moves to 260, the delete ceiling gets a name,
and the harness test now derives every term from launch.py's constants
instead of hardcoding one call at 180s, so it can fail when they move.
The gate had the matching hole: no socket timeout, and the Kaggle client
has none of its own, so a stalled call blocks forever, --soft-fail never
sees an exception and the 10 minute job timeout reports red on a
workflow whose contract is that only a failed assertion on a T4 is red.
It now sets the deadline before the first call, as launch.py does, and
the in-flight survey gets a wall-clock budget: running out of it stops
the walk as incomplete, which is already a skip, rather than being
killed by the job.
tests/kaggle: 296 passed.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Keep three non-payload outcomes from costing quota or a red check
Three ways this workflow could spend a Kaggle session, or colour a pull
request red, for something that is not the code under test.
push() filed a slug and only published it by RETURNING. The retry loop
handles the subprocess timeout, so every other raise (subprocess.run with
text=True decodes strictly and raises UnicodeDecodeError on a malformed
response; the runner can answer OSError or MemoryError) unwound past the
line that recorded the entry, and release() then iterated a list with no
entry for that notebook at all. A kernel Kaggle may have just accepted was
left billing to its own ceiling. The list is now the caller's: main()
publishes the entry before the push and push() fills it as it files each
slug, so reconciliation no longer depends on the call returning.
max_steps is free text on a dispatch and was forwarded unchecked. A value
that is not an integer reaches the payload's argparse, which exits 2 with
no report, and the generated cell reports that crash as a failing leg on
purpose, so three legs come back red after the kernels were pushed and the
model downloaded. A value below the fp16 scaler's leading skipped steps
applies no optimizer update at all, and the payload's own checks fail it.
check_steps.py answers both before the recheck, and MEASURES the floor as
the shortest prefix of the committed reference that optimisation_failures
accepts (5, on the committed trace) rather than restating a number that
would go stale as soon as the reference moved. The build step also takes
the value through the environment now rather than interpolating it.
The evidence upload had no continue-on-error, so an artifact-service
outage failed the job, and the job is what the pull request shows. The
verdict is Report's, which runs under always() and can still conclude
pass. It is best effort now, and says so out loud: continue-on-error
leaves outcome at failure while conclusion becomes success, and the step
after it reads that outcome and warns.
tests/kaggle: 302 passed.
* Bound the four checks that were stated rather than measured
Evidence collection had no deadline. The job timeout counted 300s per kernel
for it, a number no code enforced: one listing walks OUTPUT_PAGE_LIMIT pages at
the 120s socket ceiling, 2400s for a single kernel, and each executed notebook
is another 300s with no cap on how many Kaggle lists. Measured against the
launcher at the previous head, two kernels can spend 14520s there, against 600s
budgeted, and the phase runs BEFORE release(), so the runner is killed with
billable kernels up. launch.py now starts one EVIDENCE_BUDGET_SEC budget before
the first collection, shared by every kernel, clamps each listing page and each
download to what is left of it, and marks a collection truncated. The job
deadline reads that constant instead of restating a per-kernel term, and
deadline=None now means a fresh budget rather than no ceiling.
The reference band never checked the card. The reference records gpu_name
"Tesla T4" and gpu_capability "sm_75" because a loss trace belongs to its
hardware -- no bf16, xformers attention -- and every other reference-defining
property already refuses before comparing. Only the GPU COUNT was checked, on
the kernel, which cannot see the file. A P100 run against the committed T4
trace returned status "ok" with no failures, so a hardware difference read as a
code regression. check_reference now gates on the reference's own environment
block, so a recaptured reference moves the gate with it, and "it does not say"
stays unchecked rather than a mismatch.
adapter_config.json was checked for being JSON. "{}" parses, so a save that
wrote no LoRA fields at all scored config_readable and the leg passed on a
directory PEFT cannot load. The question is whether PEFT can rebuild the
adapter, so it is asked of PEFT on the path a reload takes,
PEFT_TYPE_TO_CONFIG_MAPPING[peft_type].from_pretrained; PeftConfig.from_pretrained
alone returns peft_type None for "{}" and reports nothing wrong. The rebuilt
config is then compared against the arguments the payload handed
get_peft_model, so a well-formed config for a different adapter is a difference
rather than a field list this file guessed at.
The version table omitted the packages the legs move. legs.py records the
frontier resolution installing huggingface_hub 1.27.0 and datasets 5.0.1, and
the tokenizers and safetensors ceilings that forced deps back on, none of which
were in GOAL_PACKAGES, so a failure caused by one produced a comparison table
in which nothing differed. They are recorded now. The related hole is that
pin_failures answered from a table keyed on that fixed list: a pin outside it
had no entry and came back "it is not installed" for a package installed and
correct. The probe list is derived from the pin file, and an unprobed pin is
its own third outcome rather than an invented failure.
pytest tests/kaggle: 315 passed. The workflow's recorded count is updated and
its harness step installs peft, without which the two new adapter checks
importorskip and cannot fail.
* Enforce three bounds that stopped at the edge of the thing they bound
Evidence deadline: urlopen's timeout is per blocking socket operation, not a
ceiling on the transfer, so an endpoint that keeps returning bytes renews it
forever and one resp.read() outlasts the whole 600s budget while every deadline
check sits before the call. Measured against a body arriving in 20 chunks a
minute apart: 1211s listing, 1260s download. Read in chunks with read1, the
absolute deadline re-checked before each and the live socket re-clamped to what
is left, streaming downloads to disk instead of into memory. A listing or a
notebook abandoned that way now says the evidence is truncated.
Reference hardware: check_reference compared gpu_name and gpu_capability only
when the run supplied them, and main() records environment = {"error": ...} when
the probe raises, while the fingerprint omits every gpu_* key outright when
torch.cuda.is_available() is False. Either way the live values were absent, both
keys were filed as unchecked, and the control leg reported an ok reference check
without establishing the T4 the trace belongs to. What the reference does not
say stays a skip; what the run cannot say about a key the reference does name is
now hardware_unverified and fatal.
Harness step: peft depends on torch, so installing it first satisfied that from
the default index and the CPU-index line that followed found the requirement
already satisfied and installed nothing, leaving the CUDA set on the runner in
the window reserved before the push. CPU torch goes first, as version-compat-ci
does, and the result is checked with torch.version.cuda rather than assumed.
tests/kaggle: 323 passed.
* Report an exhausted weekly Kaggle quota as a failure, not a skip
Every gate stand-down was a green skip, exhaustion included, so a week
with no accelerator hours left looked exactly like a workflow nobody had
wired up: t4-smoke skipped, no annotation, nothing to read.
The quota floor now exits nonzero carrying
GPU capacity exhausted - please wait until next week - you can ignore
this CI failure
followed by the numbers the quota API already returned (hours left, the
weekly total, and the refresh time), in the log line and in the job
summary, so the reader can see when it clears and that there is nothing
for them to fix.
Only genuine exhaustion. It is decided where it already sat, before the
concurrency survey and before any kernel is pushed, so it costs one API
call rather than a Kaggle session. An UNREADABLE quota stays a green
skip: unknown is not exhausted. So do the sampling draw, the wrong
opt-in label, the missing token, a failure to authenticate, a busy
account and the launcher-side stand-downs, all with their wording
unchanged.
--soft-fail becomes a request rather than a default. It defaulted to
True, which would have made the new red unreachable, so it is now three
states: unset, --soft-fail, --no-soft-fail. An error in the gate itself
is still a skip unless --no-soft-fail is passed, exactly as before.
The gate job asks for the red and nothing swallows it. The recheck
inside t4-smoke passes --soft-fail and stands down green instead: it
runs after approval with the account slot in hand, so reaching it means
the hours went while the run queued, and red there would be the same
message after a runner, the harness suite and the kernel build.
Ten tests pin it: the exact sentence, the exit codes on both sides of
--soft-fail, that the survey never runs once the quota is short, that an
unreadable quota and a busy account stay green, and that nothing
downstream turns the gate job's failure back into a pass. 333 pass.
* Reserve what a run can bill, and assert two results that were only recorded
Three checks that stopped at the edge of the thing they bound.
The gate reserved 4 GPU-h against a launcher whose billable window is the
whole invocation, not the polling. A kernel bills from the moment Kaggle
accepts it until a delete is confirmed, so the push retries (each one
discarding the previous attempt's slug), the evidence phase and release()
are inside that window as surely as the wait is: launch.py's own constants
put one invocation at ~13800s, and Kaggle runs at most --kernels sessions
for this account at once, each billing its wall clock once. That is
2 x 13800s = 7.7 GPU-h. The reservation is now 8, and the harness suite
recomputes the bound from launch.py rather than checking the number, in the
same helper the job deadline is derived from. The old assertion read
budget >= kernels x --max-wait, which 4h satisfied while a wedged run could
bill 7.7h and take the 20h reserve down with it.
The reference identity pinned the configuration and not the rows.
canary_dataset.jsonl is inside this workflow's paths filter, so editing it
TRIGGERS the run that would then be band-checked against a trace of the old
rows: a small edit passes the tolerance and reports green on a comparison
that means nothing, a larger one is reported as a code regression. The
payload now records a sha256 of the parsed rows in config.dataset_digest and
check_reference refuses on it exactly as it refuses on max_steps, the digest
of the committed dataset is in the committed reference, and a harness test
compares the two on the runner before any session is paid for. Digesting the
parsed rows rather than the file's bytes means reformatting does not force a
session-costing recapture; changing a row, the order or the count does.
The gpt-oss leg recorded placement_after_load on every run and asserted on
none. Its documented result is that the 20B checkpoint fits and trains
wholly on one T4 with about 1.8GB to spare, and that is the claim that
degrades quietly: a loader or memory regression that spills to CPU, disk or
meta still logs finite losses, still moves the adapter, still compiles and
still generates. failures_for now fails on parameters off the visible CUDA
device, on hf_device_map offload, and on a placement record it cannot read,
the last on the same three-way rule the bf16 reading already gets.
accelerate's offload does not support training at all, so a run that reaches
it is not a slower version of this leg.
tests/kaggle: 345 passed.
* Report a dependency import that exits the process
The verify cell's import probe caught Exception, so an import that ends in
sys.exit() went straight past it: SystemExit derives from BaseException
expressly so that code catching Exception does not swallow it. The cell then
aborted before writing the report the probe exists to write, the run cell below
it never ran, and a leg that reports nothing is partial or infra at the
launcher, both of which exit 0.
transformers is a live carrier rather than a hypothetical one: it defines
OptionalDependencyNotAvailable as a BaseException subclass and raises it at
module scope, and its own lazy loader re-raises only Exception.
So catch BaseException here, as the GPU probe a few lines below already does
for the same reason, and re-raise KeyboardInterrupt: that is the runner
cancelling the job, not a broken dependency, and recording it as one would make
a cancelled run read like a regression.
The cell is executed in the tests rather than pattern matched, so the verdict
is observed the way the launcher will read it.
* Resolve the dependencies of the distribution under test
pyproject.toml is in this workflow's trigger paths because the payloads install
the commit under test as a distribution, and every leg then installed it with
--no-deps. pip enforces the requirements of packages IN a resolution and merely
warns about the rest, so with the tested distribution outside every resolution
the file could change its dependencies and nothing here could tell: a
requirement it adds is never installed, one it tightens is never checked
against what is already there, and the import probe passes anyway whenever the
dependency is reached by a delayed code path. A user meets all three at
pip install unsloth.
The --no-deps rationale was that the overlay must not walk the set unsloth_zoo
had just resolved. What unsloth actually declares is typer, rich, pydantic,
pyyaml, nest-asyncio, structlog and click: none of them is resolved by zoo and
none is pinned by a leg, so there is nothing to walk. A pyproject that does
name one of zoo's packages would move it, which is the regression this exists
to show rather than a side effect to suppress.
Resolving is only half of it, since pip reports a conflict with something
ALREADY installed as a warning and exits 0. So the verify cell also asks pip
check, and reads only the lines pip attributes to the distribution under test:
the Kaggle image carries pre-existing conflicts of its own, and the frontier
leg installs a transformers that zoo's metadata forbids on purpose, so the exit
code belongs to the environment while only those lines belong to this leg. An
unsatisfied requirement is written out as a verdict, like the two checks above
it, because the run cell is the only other thing that reports and is never
reached from there.
The name the check keys on is read off the requirement the legs install, so the
two cannot drift into checking nothing.
* Count a kernel Kaggle says is not there as released
Cleanup reconciles every slug a push filed, not only the accepted one, and most
of those were never accepted: push() files a fresh slug per attempt, and a
retry's _discard() deletes the previous one without recording that it worked.
So release() asks Kaggle to delete slugs that were never created, and slugs
that are already gone.
A not-found answer settles the only question cleanup asks, which is whether the
slot is still billing. Reading it as a failed cleanup spent DELETE_ATTEMPTS on
an absent kernel, ahead of the accepted one that is the only kernel still
running, and then named it in a "may still be running, delete them by hand"
warning pointing a human at a slug that does not exist. The warning is the one
signal this emits, so a false one costs more than the calls.
The gate already reads a 404 as a kernel definitively not running, for the same
reason and against the same account, so this reads it through the gate's
GONE_MARKERS rather than a second list that could drift. The pinned client
surfaces one as "404 Client Error: Not Found for url: ..." on stderr with exit
1: kagglesdk calls raise_for_status and cli.py prints the HTTPError.
Every other nonzero exit keeps its retries. A 5xx, a reset connection or an
argparse refusal says nothing about whether the kernel is up, and trusting the
exit code alone would abandon a live session silently.
* Refuse to push kernels the runner may not live to delete
The job deadline is set to the launcher's worst case plus about 30 minutes for
everything that runs before it, and nothing enforces the second term. A
checkout, a pip install off a slow index, the harness suite and three network
resolutions have no cumulative limit of their own, so the allowance is an
assumption about their duration rather than a property of the run. Spend it and
the launcher still pushes, and the runner is then killed with kernels up:
GitHub sends SIGINT to the step's entry process and kills the process tree
about ten seconds later, which is not a window in which release()'s
DELETE_ATTEMPTS retries can finish. A kernel nobody deletes bills accelerator
quota to its own ceiling with nobody reading the result, which is the exact
outcome the deadline exists to prevent.
So the launcher is handed the moment the job dies and answers the question
itself, before authentication and long before the first push. Less than its own
worst case left and it pushes nothing and stands down green, which is what this
workflow's failure semantics give every infrastructure outcome: no quota spent,
and nothing learned about the code.
That worst case now lives in launch.py, computed from the constants that
produce it, because three consumers derive from it and all three are wrong by
the same amount if a phase is dropped: the job deadline, the quota the gate
reserves, and the new guard. The harness suite keeps deriving it independently
from the source text and asserts the two agree, so a term lost on either side
is a red test rather than a run that pushes with no room to clean up, or one
that stands down on every invocation.
The start of the job is recorded by the job's FIRST step. Taken any later, the
deadline computed from it sits after the real one, and the guard reads
optimistic in the one direction that costs quota. The minutes are restated in
the launch step's environment because a job cannot read its own timeout-minutes;
the suite asserts the two agree.
* Ask PEFT what the saved adapter should be called
The saved adapter is read back with safetensors and counted, which answers
whether the bytes deserialize and not whether PEFT would consume them. PEFT
matches its state dict by NAME: set_peft_model_state_dict ends in
load_state_dict(..., strict=False) and nothing reads the returned
unexpected_keys, so tensors under names it does not recognise are ignored
without a word.
Measured on peft 0.20.0 against a real saved adapter: strip base_model.model.
from every key, or leave the adapter name in (lora_B.default.weight, which is
what filtering model.state_dict() by hand produces instead of calling
get_peft_model_state_dict), and PeftModel.from_pretrained raises nothing while
every lora_B comes back at zero. The file still deserializes, still holds the
same nonzero lora_B matrices, still carries a loadable adapter_config.json, so
every reading this payload took off the bytes is identical to a healthy save
and the leg passes on an adapter that reloads to the base model.
The oracle is PEFT's own answer for this run: save_pretrained writes exactly
get_peft_model_state_dict(self, ...), so calling it on the model that was just
saved reproduces the key set the file is supposed to hold. Nothing is restated
here, no prefix and no target-module list, so a legitimate peft renaming moves
both sides at once and only a save that disagrees with the running peft is a
difference.
Three answers, and they are not one failure. A key PEFT names that the file
does not carry is a dropped weight. A LoRA tensor under a name PEFT does not
use is a weight its loader ignores. A non-LoRA tensor beside the adapter is
recorded and not failed, since save_pretrained may legitimately write an
embedding or a modules_to_save copy that PEFT never has to match. A run that
could not derive the key set at all fails rather than recording an unchecked
field, or the strongest thing this asserts would switch itself off silently.
* Compare the step count the validator parsed, not the string it was given
check_steps.parse_steps accepts "+10", "010" and surrounding whitespace as the
ten they are, and the payload's argparse agrees, so those spellings run exactly
the reference's run. The build step compared the raw dispatch string against the
reference's count instead, read an identical run as a different one, and dropped
the committed reference band from it: a green run with the only cross-run check
in this workflow never applied, announced in a warning naming a step count that
was not different.
Both sides of that comparison now come from the step that validated them.
check_steps emits the parsed count, and the reference's own count through the
payload's reference_step_count rather than a second reading of the same JSON in
shell, which also takes the reference filename back to the leg registry.
The regression test executes the build step, on the outputs a real check_steps
run wrote, with the env resolved from the step's own env block, so a build step
that goes back to the raw dispatch string fails it.
* Recheck the cleanup window after auth, and keep undecodable payload output from losing the verdict
The launcher measured the job's remaining wall clock once, before
authenticating, and pushed on that answer. With KAGGLE_API_TOKEN, the only
credential the workflow passes, kaggle 2.2.4 authenticates by introspecting the
token over HTTP, bounded only by SOCKET_TIMEOUT_SEC, so a window that fitted by
less than 120s was already gone at the first push and the runner could be killed
during release() with kernels still billing. The check is now a helper asked
twice, before and after authentication.
The generated run cell decoded the payload's stdout and stderr with
subprocess.run(text=True), which is strict: one malformed byte from a native
crash handler raised UnicodeDecodeError before the synthetic report below it,
papermill aborted the cell, and the launcher called a leg that died partial or
infra, both green. Decoding is now errors="replace".
---------
Co-authored-by: danielhanchen <unslothshared@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
|
||
|
|
1b48147d8e
|
Windows: stop depending on the generated unsloth.exe console script (#8592)
* Windows setup: install uv from a pinned release instead of running remote script text
studio/setup.ps1 piped astral's install.ps1 straight into Invoke-Expression. That
download-and-execute shape is the single construct AMSI providers and cloud ML
scanners score hardest, and install.ps1 already replaced it with a pinned-SHA-256
archive download. Port the same implementation across.
Progress goes to the pipeline rather than the console, so the quiet path swallows
it exactly as it swallowed astral's installer output and the printed lines around
the call site are unchanged.
* Windows: stop pairing a hidden window with a bypassed execution policy
The Studio shortcut launched launch-studio.ps1 with -WindowStyle Hidden and
-ExecutionPolicy Bypass on the same command line. That pair is what Microsoft's
own detections key on, and studio/src-tauri/src/install.rs already refuses it for
the app's own launch of install.ps1.
The installer writes launch-studio.ps1 itself, so the file carries no
mark-of-the-web and RemoteSigned loads it. The hidden window is unchanged, so the
shortcut behaves exactly as before. The generated launcher's own child launch
moves to RemoteSigned for the same reason: it runs an inline -Command against an
executable, where no script file is loaded and the two policies are equivalent.
Also refresh a stale comment in studio/setup.ps1 that attributed the PSModulePath
fix to astral's uv installer, which no longer runs in-process.
* Installers: keep download-and-run command lines out of the shipped script text
AMSI scans install.ps1 in full before a single line of it runs, and generic
script classifiers read install.sh the same way inside the Linux bundle. Both
headers rehearsed the piped web one-liner five times over, plus a scriptblock
form and an execution-policy bypass, none of which anything in the scripts reads
and all of which the README already documents.
Point at the README instead and reword the in-body comments that quoted the
one-liner as shorthand. Every printed line is untouched: the remediation text the
installers show users still spells out the command in full.
Same treatment for scripts/uninstall.ps1's header.
* Windows: resolve process image paths with one Win32_Process query
install.ps1's venv-holder probe opened a handle to every running PID through
inline C# compiled at runtime. Opening a handle per process is a shape AV
heuristics score hard, and it bought nothing: Win32_Process reports
ExecutablePath for exactly the processes those handles could be opened against,
and answers for all of them in a single query instead of once per PID.
The remaining file-canonicalisation imports stay -- handle-based resolution of
linked ancestors has no faithful Windows PowerShell 5.1 equivalent, and it runs
on security-relevant paths.
Falls back to the per-process .Path when the query is unavailable, so a degraded
WMI repository degrades exactly as the old code did on a process it could not
open.
* Desktop: say who blocked the install when AMSI stops the script
PowerShell hands the whole top-level script block to AMSI while compiling it, so
a security product's verdict arrives as a parse error over the entire file before
install.ps1 runs a statement: no [TAURI:ERROR] marker, no phase log, and a stderr
tail the user cannot act on. unsloth#8523 shows what that looks like in the UI --
"Installation failed: + FullyQualifiedErrorId : ScriptContainedMaliciousContent".
Recognise the two stable error ids on either stream and append what the user
actually needs: nothing was installed, nothing was changed, it is a false
positive, update definitions and retry, do not turn off endpoint protection. The
raw id stays in the message, because the diagnostics report and any vendor
submission both need it.
Matches the id, never the message text, which is localized, and tolerates the
cmdlet suffix the Invoke-Expression form carries.
* Desktop: ship each bundle only the installer it can run
resolve_install_script picks install.sh on unix and install.ps1 everywhere else,
but the shared Tauri config bundled both into every target. The Linux AppImage
therefore carried 280 KB of Windows PowerShell it can never execute -- and it is
the largest script body a generic classifier walking the squashfs reads, which is
where Microsoft's Trojan:Script/Wacatac.B!ml verdict on 0.1.701-beta landed.
Move the resource map into the per-platform configs. The clean-machine job
already fails when a Linux bundle ships no install.sh; it now also fails when one
ships install.ps1, so the split cannot silently regress in either direction.
The .deb scanned clean with the same payload, so this is surface reduction rather
than a proven fix for that verdict.
* POSIX installers: install uv from a pinned release before falling back
install.sh downloaded astral's install.sh to a temp file, ran it and deleted the
file; studio/setup.sh piped it straight into a shell. Both are, shape for shape,
what a dropper does, and generic ML script classifiers score them accordingly --
the 0.1.701-beta Linux AppImage came back Trojan:Script/Wacatac.B!ml while the
.deb carrying the same scripts came back clean.
Fetch the pinned release archive and verify a hardcoded SHA-256 instead, matching
what install.ps1 already does on Windows. Only the four mainstream targets are
pinned: musl, armv7 and any host without a digest tool keep the path they have
today, because guessing a target triple wrong would break the install outright
and that costs far more than the heuristic score of the fallback.
Destination, PATH handling and every printed line are unchanged, so a host that
takes either path ends up in the same state it did before.
* tests: pin the installer shapes antivirus heuristics score
One file collecting what was removed, so it cannot drift back: no remote script
run in-process, no encoded or base64 payload, no hidden window paired with a
bypassed execution policy, no handle opened against another process, and no new
runtime-compiled native import outside an allowlist that carries a reason for
each entry that stays.
The last test is the other half of the contract. Hardening must not change what a
user sees, so the remediation lines the installers print -- which still spell out
the web one-liner in full -- are asserted verbatim. Removing the one-liner from
comments is the point; removing it from what the user is told to run would be a
regression.
Runs on the existing discovery-based pytest step, no workflow list to update.
* release: emit a false-positive submission packet for whatever gets flagged
The build job assembles a Microsoft submission packet, but only for the Windows
-setup.exe. The detection that actually arrived on 0.1.701-beta was
Trojan:Script/Wacatac.B!ml on the Linux AppImage, so nothing was produced for the
one asset that needed it.
The VirusTotal job already knows which assets were flagged and by which engines,
so put the packet there: hash, size and both portals, for every flagged asset
whatever platform it came from, with a note that clearance is per hash and per
vendor. Engine names are not repeated -- they are third-party text and already
appear escaped under Flagging engines.
The gate stays advisory; this only makes acting on it take seconds.
* Revert "Windows: resolve process image paths with one Win32_Process query"
This reverts commit
|
||
|
|
ce3f5c90ed
|
Studio: set DYLD_LIBRARY_PATH for llama-server on macOS, and classify macOS startup failures (#8574)
* Studio: give llama-server a dyld search path on macOS, and stop losing macOS startup diagnostics _llama_server_env_for_binary branched win32 vs "everything else", and that else branch is Linux: WSL/ROCm probing, pip nvidia wheel globs, CUDA toolkit paths, and LD_LIBRARY_PATH. dyld ignores LD_LIBRARY_PATH, so llama-server was launched on macOS with no library search path at all, while the installer's own staged-binary validation does set DYLD_LIBRARY_PATH and therefore passed on a path the real launch never took. sd_cpp_engine and stt_ggml_sidecar already map the variable per platform; llama_cpp.py was the outlier. Add the darwin branch plus a shared _loader_path_var(), and use it at the two other sites that equated "not Windows" with LD_LIBRARY_PATH (the Vulkan probe and the CPU fallback replay). Classification was Linux-only too. dyld shares no wording with glibc, so "Library not loaded", "Reason: tried: ... (no such file)", an invalid code signature, an incompatible architecture and "built for macOS X which is newer than running OS" all fell through to "check that the GGUF file is valid and you have enough memory" - advice about a file and a resource that were never the problem. Classify them, reusing the existing provenance-aware missing/unloadable library messages, and keep them ahead of the returncode heuristics: a dyld diagnostic is a fact, signal 9 is a guess. macOS also SIGKILLs a binary whose code signature is invalid before it can print anything, so signal 9 there now offers both readings instead of memory alone. Finally, make the unknown-failure fallback self-diagnosing. Studio already writes every byte of llama-server's stdout and stderr to a per-attempt log, and already keeps the last 50 lines in memory, but the 400 response threw both away precisely when nothing recognised the output. It now carries a bounded, control-character-stripped output tail and the log path. With neither, the old message is returned byte for byte. Reported in #8566, where a Mac could load no GGUF at all and the error named the two things that were fine. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Pin the platform in the SIGKILL test macOS SIGKILLs a binary with an invalid code signature the same way the OOM killer does, so the -9 message names both readings there. The existing test asserted the .wslconfig wording against whatever host it ran on, which made it fail on the macOS runners. Pin it to the non-Darwin platform and assert the two messages stay distinct. * Update the capability-probe env test for the macOS loader path The test simulated darwin and asserted the binary's dir landed on LD_LIBRARY_PATH, which is the bug: probe_server_capabilities goes through _llama_server_env_for_binary, so on macOS it ran llama-server --help with a variable dyld ignores. Assert DYLD_LIBRARY_PATH instead, and that an inherited LD_LIBRARY_PATH is left as the user set it. * Address review: bound and scrub the diagnostics, judge the right dyld candidate Six fixes from the review of the first two commits. A stray "Reason:" anywhere in the output used to be read as dyld's, because the capture was DOTALL and matched the first occurrence anywhere, running to the end of the output. llama.cpp prints its own "Reason:" lines, and Studio appends its own health-timeout marker, so the classifier could quote either back as dyld's diagnosis, or pick a verdict from a line belonging to a different failure. The reason is now bounded to its own line plus the indented continuations and read only after the "Library not loaded:" it explains. dyld4 lists every path it searched with a per-path verdict, so a mixed list is normal: our own dylib missing, and a leftover Intel Homebrew copy under /usr/local reporting an incompatible architecture or a policy-blocked signature. Scanning the flattened list let the scariest entry decide, which told users their install had been tampered with when a file was merely absent. Parse the "tried:" list and judge the candidate in the runtime's own lib dir. A stray "Reason:" also disabled the symbol-mismatch branch entirely, since the lib/reason block returned instead of falling through. "@rpath/libfoo.dylib" was passed to the shared message helpers as if it were a path, producing "missing from that exact location" for a search directive that is not a location. Strip the directive down to the soname. The macOS SIGKILL message hardcoded `unsloth studio update`, which cannot touch a pinned LLAMA_SERVER_PATH, and promised the log would say which cause it was when a signature kill leaves the log empty. Route it through _runtime_remedy and let it carry whatever diagnostics exist. The output tail is llama-server's own stdout, and llama-server inherits nearly all of Studio's environment, so a wrapper or diagnostic build echoing its env would have put an API key in the load error. Redact credential-shaped env values, plus bare hf_ and Bearer tokens, and slice the tail before filtering it character by character so a runaway unterminated line is not walked in full. Also fixes the same missing dyld search path in the RAG embedding server, which Apple Silicon reaches through its use_gpu branch. It is the same bug as the chat server's, in a second launcher. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Assert Studio's llama-server launch environment in the macOS CI The Mac job already installs a real llama.cpp and launches llama-server to prove it loads on that host, but it launches it from the shell's own environment, which says nothing about the one Studio builds for its child. That environment was Linux-shaped on macOS (LD_LIBRARY_PATH, which dyld ignores), and the installer's own staged-binary validation does set DYLD_LIBRARY_PATH, so nothing at install time could see the defect. Assert it here, where a real runtime is installed. A unit test with a monkeypatched sys.platform can only prove the branch is taken; this proves the launch environment points dyld at the dylibs that are actually on disk. Runs under the interpreter the `unsloth` shim points at, so the backend imports resolve without installing anything extra, and skips cleanly if no shim is on PATH. Verified on macos-14: fails on the pre-fix revision of llama_cpp.py, passes on the current one. * Cover the CPU, wrapper and probe paths the first macOS fix missed Five follow-ups from the second review round, all consequences of the first fix not reaching far enough. The RAG embedding server only got the dyld search path on its GPU branch, because it was bolted onto the CUDA helper. A CPU start (EMBED_DEVICE=cpu, or the retry after a failed GPU start) loads the same sibling dylibs, so the path now applies whichever device it lands on. That server also passed Path(binary).parent as the library directory, while the chat backend resolves the entrypoint with _llama_lib_dir. The managed install puts an entrypoint in front of the real server, so on a normal install the wrapper's own directory was named instead of the one holding the dylibs. Its capability probe ran `llama-server --help` with no loader environment at all, ahead of any launch. A bundle that needs the search path dies in the loader there, and the error text reads as help output with no --embedding in it, so the user was told their build lacks embedding support instead of that it failed to load. The probe now runs under the same environment as the launch. On macOS the launch resolves a shell entrypoint before spawning it. SIP purges DYLD_* while starting the protected /bin/sh, so the loader path did not survive a wrapper's exec of the real binary. The wrapper does nothing but exec the target, so this is the same launch without the shell hop. Secret redaction in the diagnostics tail now uses the same predicate that decides what to strip from a managed server's own environment, plus URL userinfo and common token shapes. The previous name-marker list missed DATABASE_URL, REDIS_URL and GITHUB_PAT, which is exactly the class of variable whose name says nothing about its contents. * Read the symbol's provenance, and stop the CI check from skipping itself Two follow-ups from the third review round. A "Symbol not found" with no missing file was always reported as llama.cpp libraries from different builds. That is right when dyld expected the symbol in one of our dylibs, and wrong when it expected it in a system framework: there the build wants a newer macOS than this one, and reinstalling the same build cannot make the OS export the symbol. Read the "Expected in:" path and pick the remedy from it. The MTLResidency special case is the same failure, now covered by the general rule as well. The macOS launch-environment assertion looked the interpreter up with `command -v unsloth`. The clean-machine lane scrubs PATH to system directories and puts the shim under its own UNSLOTH_STUDIO_HOME, so the lookup came back empty exactly there and the check skipped, while the job still reported success. It resolves from STUDIO_HOME first now, and a missing interpreter fails rather than skips: an install that produced a llama-server but no reachable interpreter is a broken install, and a skip is indistinguishable from a pass. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Cover the tauri venv, the embedding entrypoint, and the launch api-key Three follow-ups from the fourth review round. The interpreter lookup added last round misses the tauri delivery, which nests its venv one level deeper at $STUDIO_HOME/studio/unsloth_studio. Since the same commit made a missing interpreter fail rather than skip, that turned a good tauri install into a red job. Both layouts are candidates now. The embedding server built its loader environment from the resolved directory but still probed and launched the entrypoint itself, so on macOS SIP would take DYLD_* away again on the way through the wrapper. It resolves the executable once, before the capability probe, so both the probe and the spawn run the real binary. The startup diagnostics could echo our own --api-key. It is minted per launch with secrets.token_urlsafe(32), so it is in no environment variable to look up and matches no token shape; the log path already treated it as sensitive but the API error did not. It is now redacted by its flag, and the live value is handed to the scrubber so it goes wherever it appears. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Scope the macOS resolution and the redaction more tightly Five follow-ups from the fifth review round, all narrowing something the previous rounds made too broad. Resolving a shell entrypoint past its exec line is right for the one the installer generates and wrong for a user's own: a custom LLAMA_SERVER_PATH wrapper may export backend variables before it execs, and jumping to the target would drop that silently. Resolution is now gated on the binary being ours, through a single _exec_path_for_launch used by the chat launch, the capability probe and the embedding backend. That probe needed it too. Only load_model resolved, so the startup, status and preflight routes still probed the wrapper, lost DYLD_* to SIP, and read back inconclusive capabilities, which clamps parallel slots and disables DSpark and DFlash sizing on a server that supports both. Stripping only the leading directive left "@loader_path/../Frameworks/libomp.dylib" as "../Frameworks/libomp.dylib", still path-like enough to earn the same "missing from that exact location" advice one level in. Placeholder install names reduce to the library name now. The classifier is pure text and runs on every platform, so keying a macOS diagnosis on the words "Symbol not found" claimed a Linux wrapper's plugin error as a mixed macOS runtime, and suppressed the output tail that would have shown what really happened. It now requires dyld's own framing. Redaction skipped any value under eight characters, which is the right rule for a port number and the wrong one for DATABASE_PASSWORD=hunter2. Short values from secret-named variables are redacted where they appear beside their name, rather than globally. * Tighten the comments this change adds Comment-only pass over the new code: same intent, 27 fewer lines. The explanations of why macOS needed each of these were written while working the problem out and read like it. * Compare the binary revision in the path space the launch recorded _binary_revision keys on the path string, and the macOS launch now resolves a managed entrypoint to its target, so the changed-since-launch check compared a resolved path against an unresolved discovery path. On a managed install, where the entrypoint is a symlink or wrapper, those never match, so an unchanged llama.cpp read as a fresh update and every Apply reloaded the model. Resolve on the comparison side too. The regression test fails without the fix, and its sibling pins that a genuine update is still detected. * Treat a --with-llama-cpp-dir tree as the user's own _is_unsloth_managed_binary asked only whether the binary sits under the managed root, which a --with-llama-cpp-dir install does: setup.sh makes the canonical llama.cpp directory a symlink to the user's checkout. The update flow already detects that case and refuses to write through the link, so the same install was being called managed here and unmanaged there. Two consequences, both now fixed by consulting the same predicate the update flow uses: the macOS launch resolved past the user's own entrypoint, dropping whatever setup it does, and a failure told them to run an updater that declines to touch their tree. * Pin the inherited value instead of asserting the key is absent The macOS embedding-env test asserted LD_LIBRARY_PATH was not in the child env at all. That env is a copy of os.environ, so the key is there whenever the ambient environment has it, whatever this branch does: the test passed alone and failed in a full run. Set a sentinel and assert it survives untouched, matching how the chat-backend test states the same invariant. * Scope the non-macOS behaviour changes out, and bound the dyld parsing Five independent audits of this branch, all asked the same question: is anything outside macOS different afterwards. Everything they found that was, is fixed here, each one reproduced against origin/main first. Cross-platform behaviour restored: - The macOS loader classifier ran on any output that merely contained 'Library not loaded:' or 'Symbol not found'. llama.cpp echoes GGUF metadata to stderr while loading, so a model whose general.name holds either string got library advice, and on two paths that outranked a correct answer: status 127 and signal 15. Every branch now requires dyld's own line-anchored framing. - _is_unsloth_managed_binary learning about --with-llama-cpp-dir also moved the gate on the Vulkan CPU fallback, which reads and copies the tree and needs no updater. Split into a second predicate so those installs keep the fallback on every platform. - The embedding capability probe was given a rebuilt environment, and its GPU library dir was resolved through the entrypoint, on all three platforms. Both are macOS-only now; Linux and Windows are byte for byte as they were. Correctness: - An exact LLAMA_SERVER_PATH pin now outranks inferred ownership. A wrapper pinned inside a managed tree read as ours and was resolved past, dropping whatever it exported before its exec line. - A quoted 'Expected in:' path failed the /System/ test, so a too-new build was blamed on a mismatched install. - Older dyld says 'mach-o, but wrong architecture'; 'Bad CPU type in executable' never reaches dyld at all. Both were falling through. - The signal 9 wording no longer reads as an exhaustive pair of causes. Bounds and idempotency: - The tried-candidate scan went quadratic on adversarial output: 100KB of "'a' (" took 6.3s against 0.0s on main. The reason is windowed before matching, capped after it, and candidates are counted. - _with_startup_diagnostics recognises its own output, so a second call cannot double the tail. One call site exists, so this cannot fire yet. - One shared loader-path prepend, deduplicating on the normalised spelling, and the embedding one returns a new dict instead of editing the caller's. - Bearer matching covers the full token68 alphabet and Basic. Verified by differential probes against origin/main: 1080 child-env scenarios over platform x GPU vendor x install layout x inherited environment differ on darwin only, and 1392 classifier scenarios differ only where dyld is genuinely the cause or the generic fallback gains its new output tail. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Make three loader tests independent of the host they run on They simulate a POSIX platform by monkeypatching sys.platform, but os.path and pathlib stay the host's, so on the Windows runner Path('/opt/llama/bin/llama-server').resolve() came back drive-anchored and splitting an LD_LIBRARY_PATH on ':' cut 'C:\...' after the drive letter. Product behaviour is not involved: the darwin branch never runs on Windows, and the Linux branch's own separator is correct there. Paths now come from tmp_path, and the ordering assertion uses startswith rather than splitting on a character that appears inside Windows paths. * Stub the install-tree gate where the CPU replay tests stub provenance _cpu_isolated_binary now asks _is_llama_install_tree rather than _is_unsloth_managed_binary, and three fixtures stub only the latter, so the gate fell through to the real _llama_install_root on a tmp_path with no install marker and every staged-runtime test got None. Product behaviour is not involved. Checked directly against origin/main on four trees: a marked install, a --with-llama-cpp-dir link, an unmarked directory and a binary pinned outside any install. The new gate returns what main's gate returned in all four, including restoring True for the link, which is the case this PR was fixing. * Use the Sequence import instead of hiding it in a string annotation verify_import_hoist flagged Sequence as added-but-unused: the three annotations that need it are quoted, so nothing references the name at module scope. Both Sequence and Optional are unconditional imports here, so the quotes bought nothing. Unquoting them makes the import real. scripts/verify_import_hoist.py --before origin/main --after now reports PASS rather than OVERALL: FAIL. * Follow a wrapper chain, and make re-classification a fixed point Two findings from a second round of ten independent audits, both reproduced against origin/main first. _resolve_llama_binary followed one hop. A wrapper whose target is another wrapper resolved to the intermediate script, which on macOS is the very defect this fix exists to avoid (SIP drops DYLD_* through the shell) and also handed _llama_lib_dir the wrapper's directory instead of the one holding the dylibs. It now follows the chain, bounded, and stops on a repeat so a pair pointing at each other cannot spin. _with_startup_diagnostics guarded its "message" argument, but the composition that actually grows is feeding a classified result back in as the child "output": 222 -> 413 -> 604 characters over three passes, and a specific dyld diagnosis downgraded to the generic fallback. The detector is now shared and applied to both sides. A classified message carries no marker of its own, so one wrap still happens before it settles; tagging the return type to close that too is a bigger change to a widely used error path than an unreachable property justifies, and the test says so. No caller feeds either composition today. These keep stated properties true rather than fixing live bugs. * Skip only the entrypoint the installer wrote, and redact quoted secrets Two P2 items from this round, both reproduced against head first. _llama_install_root treats the directory named by UNSLOTH_LLAMA_CPP_PATH as the active install with no marker file needed, so provenance alone answered "ours" for a wrapper at the root of a user's own checkout, and launching its target dropped whatever it exported. The LLAMA_SERVER_PATH pin added earlier covers only the case where they name the file. install_llama_prebuilt writes a fixed three-line wrapper, so that exact shape, a symlink, or a real executable is now the condition for resolving past an entrypoint; anything else is theirs and is launched as written. _resolve_llama_binary keeps resolving any wrapper, because _llama_lib_dir needs the directory holding the dylibs and that is the target's directory whoever wrote the script. The name-adjacent rule for environment values under eight characters matched only a bare NAME=value, so DB_PASSWORD='hunter2' and the JSON form went into the startup-output tail intact. It now allows quotes around the name and the value, requiring the same quote on both sides so an unrelated neighbouring quote cannot be swallowed. Verified before and after on a markerless custom directory and on five env-dump spellings; 680 tests pass. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Widen the tail window by the longest secret before redacting _scrub_secret_values matches a whole value, so a credential longer than the 8000-character prefilter lost its head to the slice that runs before it, and the surviving suffix matched neither the literal nor any token shape. A PEM key or a service-account blob dumped by a wrapper therefore reached the API error. Reproduced with a 12010-character MY_PRIVATE_KEY: the marker at its end came through before, and does not now. The window is widened by the longest value the scrubber could be asked to match, capped at 256KB so a pathological environment value cannot turn a bounded slice into a scan of everything the child printed. The final 2000 character bound on the emitted tail is unchanged. Worth recording: a secret SHORTER than the window cannot straddle its boundary, since a value whose end is inside a window wider than itself has its start inside too. Only values longer than the window were ever at risk, and a test pins that reasoning. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Decide on the wrapper's shape alone, not on how it was reached The exact LLAMA_SERVER_PATH pin added two rounds ago short-circuited resolution outright, to protect a custom wrapper's setup. That was too broad. Pointing LLAMA_SERVER_PATH at the installer's own entrypoint is a supported configuration, and refusing to resolve it just because it was named put the launch back through /bin/sh, where SIP drops DYLD_* before the real binary runs, reproducing #8566 for that setup and for the capability and embedding probes with it. _is_installer_entrypoint already answers the real question, so the pin check is gone. An installer-template wrapper does nothing but exec its target, so resolving past one is never a loss however it was reached; anything else is somebody's own script and is launched as written however it was reached. Reproduced inside a marked install tree before and after: a pinned template wrapper now resolves to build/bin/llama-server, a pinned custom wrapper is still launched as written. 684 tests pass, and the 1080-scenario child environment differential still differs from main on darwin only. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Never trust the child's output for being our own framing _classify_llama_start_failure returned the child's stdout unchanged when that stdout carried the framing _with_startup_diagnostics writes. I added that early return two rounds ago to keep re-classification a fixed point. It was a bad trade: the guard reads untrusted text, so a wrapper or a diagnostic build whose first line happens to be "llama-server output:" or "Full log: " had its whole stdout returned as the API error, past the redaction and past the 2000-character cap. Measured on the branch before this commit, with an environment dump behind the heading: 50067 characters returned verbatim with the credential intact and no log path, against 2136 characters, scrubbed, for the same bytes without the heading. Removed the early return. The only guard on our own framing is the one in _with_startup_diagnostics, which reads the message this module built itself, and that one is safe. The property this drops was for a caller that does not exist. What replaces it is the property that actually matters and is now pinned by a test: repeated passes stay bounded. Each pass wraps the previous message in a fresh tail, roughly 136 characters, and the tail cap stops it at 2136 no matter how many times it runs. Tests: the fixed-point test is replaced by a bounded-growth test plus a parametrised regression test covering all four ways the heading can appear. 141 tests in the classification suite, 688 across the changed areas. * Do not let the child's output rewrite the diagnosis Attaching a tail of llama-server's own stdout to the failure message had a consequence I missed. The inference route scans the whole error string for unsupported-model phrases: _NOT_SUPPORTED_HINTS = ("No config file found", "not yet supported", "is not supported", "does not support") Those now match inside the quoted child output. So a llama-server that prints a line like "ggml_vulkan: device Intel(R) UHD does not support 16-bit storage" gets its failure rewritten to "This model is not supported yet. Try a different model." The model was fine; the message sends the user to replace it. llama.cpp prints lines of that shape for ordinary reasons, so this was reachable on Linux and Windows as much as on macOS. Reproduced on the branch before this commit with three different phrasings. _diagnosis_text() cuts the message at the diagnostics block, and both the unsupported-model matcher and the NVFP4 matcher now decide on the part this backend wrote. The evidence still reaches the user, and is still quoted in full when a rewrite does happen; it just no longer votes on the diagnosis. A message with no diagnostics block is returned unchanged, so every other error source is untouched. Tests: 11 new, covering four real llama.cpp phrasings, the same phrase in our own text (still rewritten), our text carrying a tail (still rewritten, evidence preserved), the NVFP4 matcher, and four shapes that must pass through unchanged. 710 tests pass across the changed areas. Whole backend suite against origin/main: 244 failures on each side, the same 244, no regressions. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Redact by name as well as by value, and bound the library name Two findings from a fresh adversarial pass over the diagnostics tail, both reproduced on the branch before this commit. Redaction missed an encoded secret. _scrub_secret_values matched the credential's literal text, so it only worked when the child printed the value verbatim. A wrapper dumping its environment as JSON prints DB_PASSWORD = pa"ss\word-12345 as {"DB_PASSWORD": "pa\"ss\\word-12345"} which is a different string. The escaped form went into the API error fully reconstructible. URL-encoding it did the same. _redact_secret_assignments replaces whatever sits beside a secret-looking NAME, in the three shapes an environment dump takes: shell-ish, JSON and bare. Positional, so the encoding does not matter, and it also covers a value we never set and therefore could not have matched on. The old value pass stays, because it catches a credential under a name that does not look like one. The remaining gap is an unrecognised name AND an escaped value. Checked against over-redaction, which would defeat the point of the tail: PATH, port, n_ctx, model_path, timings and llama_model_loader lines are all untouched. Both regex arms are unambiguous single-character alternations, so the pass is linear; 200000-character pathological inputs run in 7 to 52 ms. The library name was unbounded. A dyld install name is a path and macOS PATH_MAX is 1024, but nothing enforced that on untrusted output: a 200093-character input produced a 100444-character HTTP error. Capped at 2048 with an ellipsis, which cannot truncate anything the loader could have produced. A real name is unchanged. Tests: 22 new across encoded secrets, the never-set value, six over-redaction controls, four linearity checks and the name cap. 722 pass across the changed areas. The 1392-scenario classifier differential against origin/main is unchanged: 540 non-dyld differences, all still strictly additive, 0 violations. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Redact every classified branch, fix the quoted arm, stop at custom wrappers Three findings, all reproduced on the branch before this commit. Two of the three are defects in code I added earlier in this PR. Redaction only covered the tail. _scrub_secret_values ran inside _with_startup_diagnostics, so it protected the fallback and nothing else. Every other branch also quotes untrusted text: a dyld message names the library it could not load, and that name comes from the child. A credential appearing there went out in the API error while the same credential in the tail was starred out. Verified with a token as the library name: leaked before, redacted now. The fix is one boundary rather than one guard per interpolation, which is the arrangement that let this through in the first place. The classifier body moves to _classify_start_failure_text and the public function scrubs whatever comes back. Scrubbing twice is a no-op, since a redacted value no longer matches. The quoted arm ended on either quote character. So a JSON value holding an apostrophe, or a shell value holding a quote, failed to match, fell through to the bare arm, and that arm stops at whitespace: {"DB_PASSWORD": "prefix' supersecret"} left ` supersecret"` standing in the API error. The arm now closes on the delimiter it opened with. Still linear, still an unambiguous alternation. Launch resolution stepped over somebody else's wrapper. Approving the outer entrypoint says nothing about what it points at, so an installer-shaped symlink whose target was a hand-written wrapper had that wrapper skipped on macOS, along with any exports it set, even though running the symlink directly would have executed it. _resolve_llama_binary grows a template_only flag: launching stops at the first link that is not the installer's own template, while the library-directory lookup keeps following the whole chain, because that is where the dylibs sit whoever wrote the links. Pinned by a test on each of the three shapes. Tests: 9 new. 731 pass across the changed areas, 1482 across every llama.cpp and secret-handling suite. Both differentials against origin/main are unchanged: 1392 classifier scenarios with 540 non-dyld differences, all still strictly additive and 0 violations, and 1080 env scenarios differing on darwin only. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Close three more holes in the name-anchored redaction All three reproduced on the previous head. Each is reachable by a wrapper or crash handler that prints its own configuration rather than the environment we can match values against, which is the case the name pass exists to cover and where it was weakest. An unterminated quoted value leaked its tail. A truncated line ends the output mid-value, so DB_PASSWORD="prefix supersecret has no closing delimiter, cannot match the terminated arm, and fell to the bare arm, which stops at whitespace and left ` supersecret` behind. A third arm now runs an opened-but-unclosed quote to the end of the line, ordered after the terminated arm so a properly closed value still prefers that one. The opening delimiter is kept and no closing one is invented, since that would misreport what the child printed. Only the value's line is consumed; a following line survives. A config-style key was never tested. The name accepted shell identifiers only, so `db-password` and `api-key` never reached is_secret_env_name at all. The name now accepts hyphens and dots, and the separator is normalised to an underscore before the predicate is asked, because the predicate's markers are underscored (API_KEY, PRIVATE_KEY). The predicate stays the one place that decides what counts as a secret. Checked hard against over-redaction, since a wider name is the obvious way to make this worse: model-path, ggml.backend, n-gpu-layers, cache.type-k, llama_model_loader and timing lines are all untouched. Overlapping literals masked each other. With TOKEN_A=abcdefgh and TOKEN_B="abcdefgh VERYSECRET", replacing the short value first rewrote the long one's only occurrence to "*** VERYSECRET", after which the long value no longer matched itself and its tail went out. The minted secrets and the environment's own values are now collected into one set and replaced longest first, so a credential cannot partially mask another before it is scrubbed. Order-independent, verified both ways round. Tests: 20 new. 185 in the classification suite, 1715 across every llama.cpp and secret-handling suite. Both differentials against origin/main unchanged: 540 non-dyld classifier differences, all strictly additive, 0 violations; 1080 env scenarios differing on darwin only. Linearity re-checked on the widened pattern: 200000-character inputs in 15 to 61 ms. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> |
||
|
|
72ab966221
|
Installer: suppress macOS uv developer tools dialog (#8479)
* Desktop: stop the loopback client following redirects `loopback_http::client` is the client that posts `.desktop_secret` to /api/auth/desktop-login, and it was built without a redirect policy. reqwest follows up to 10 redirects by default, and its cross-host protection strips headers rather than bodies, so a responder answering 307 (which preserves the method and the body) would carry the secret to whatever the Location header names, after the loopback URL had already been checked. Its sibling `streaming_client` already refuses redirects for exactly this reason: "Redirects are refused so a loopback URL cannot be bounced off-host after the check." Give `client` the same policy. No behaviour change for any real backend, which never redirects these routes. * Suppress macOS uv developer tools dialog * Update workspace guard for uv wrapper * Stop the installer raising the macOS command line developer tools dialog On a Mac without the Command Line Tools, /usr/bin/git, lipo, install_name_tool and friends are libxcselect shims. Executing one resolves no developer dir and posts to com.apple.dt.CommandLineTools.installondemand, which draws the 'requires the command line developer tools' dialog naming the tool. Resolving the path does not; only execution does. Two call sites execute a shim on the consumer path: _has_working_git ran 'git --version' to decide whether git works, so on a clean Mac the probe raised the dialog it exists to detect. It now answers from the resolved path when that path is exactly /usr/bin/git and no toolchain is selected. Deliberately narrow: a Homebrew, MacPorts or Xcode.app git earlier on PATH is a real binary and is still probed by executing it, so a Mac with a working git but no CLT selected behaves exactly as before. An earlier version of this gated on 'no CLT implies no working git' and broke that case, which the existing test caught. xcode-select -p only asks which toolchain is selected and never prompts. The venv arch probe called lipo first and fell back to file -L. lipo is a shim; 2>/dev/null hides its stderr but not a GUI dialog. file is base system and always answers, so the order is swapped. Both spellings feed the same case below, against 'Mach-O 64-bit executable arm64' or 'universal binary ... [x86_64] [arm64]' rather than lipo's 'arm64' / 'x86_64 arm64', so the branch taken is unchanged. clean-machine-assert.sh already made this same swap for its own use. The cctools binaries were missing from the clean machine CI tool list, so none of this was visible: trace mode generated no wrapper and the absent list never checked them. install_name_tool, lipo, otool, objdump, vtool, strip and nm are added, which is what makes these fixes regression testable. test_macos_clt_gate.sh gains two cases pinning the contract: with a shim git and no toolchain selected the probe answers no WITHOUT executing it, proven by a stub that records execution into a marker file, and with a real git elsewhere on PATH the stub IS executed. The first assertion passed vacuously when written (wrong temp path meant the marker could never be created) and was fixed by making its pair fail first. 18 to 23 passing. * Preserve working git on Intel macOS * Keep the git shim guard on under Rosetta --------- Co-authored-by: danielhanchen <unslothai@gmail.com> Co-authored-by: danielhanchen <danielhanchen@users.noreply.github.com> Co-authored-by: danielhanchen <danielhanchen@gmail.com> |
||
|
|
67af9aa825
|
Desktop: unify normal release updater flow (#8298)
* CI: point desktop updater test at fork * Studio: simplify desktop setup progress UI * CI: limit updater A/B build to macOS and Windows * CI: publish macOS and Windows updater test assets * CI: build Linux and Windows updater test assets * CI: pin updater test to Windows 2022 * Fix latest-main updater test workflow merge * Resolve latest-main startup message merge * Desktop: unify normal release updater flow * Desktop: validate updater signatures * Desktop: preserve generated updater signature * Restore macOS and target the existing v release for PR #8298 Restores the macOS leg that was dropped from the release pipeline: the macos-latest matrix entry, the .dmg and .app.tar.gz assets, the darwin-aarch64 platform entries in latest.json, and darwin-aarch64 in the required families of both release-desktop.yml and publish-desktop-updater.yml. Without them no macOS bundle is published and macOS clients find no matching platform in the manifest, so they stop updating entirely. Targets the v{version} release that already exists instead of creating it. The tag is cut when main is tagged, before this workflow is dispatched, so the old "tag already exists" guard failed every run on this repository; it only passed on a fork where the tags were absent. The guard now requires the release to exist and refuses only when it already carries desktop assets, naming the delete-asset commands to recover a failed publish. Provenance is appended to the release body rather than replacing it, since that body is the changelog. Windows step conditions follow the restored windows-latest matrix entry. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address the review on PR #8298 Gate the asset and manifest uploads on the draft input. The target release is already public, so a validation-only run was publishing unapproved binaries and latest.json to it. Move the provenance edit after the uploads and replace any earlier section instead of skipping it, so a retry that follows a partial upload records the digests that actually shipped rather than the previous build's. Reject a prerelease target in both guards. GitHub cannot mark a prerelease latest, so catching it only at promotion left the bundles already public. Re-read GitHub latest immediately before promotion. The downgrade check runs before a build that can take an hour, and promoting past a newer release would hand every client an older manifest. Build from the release tag rather than the dispatch ref. The release is published before the workflow runs and main keeps moving, so the bundles could come from unrelated source and provenance could record a SHA that is not the tag's. Skip the updater validation when the release carries no latest.json. The v release is published before the bundles land, so the release event fired first and failed on every release; it now fails closed only when the release cannot be read. Scope the signature sweep to the desktop bundles, since the release is shared. Add the AGPL-3.0 header to the two new files, in the style the repository uses. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Keep updater discovery on desktop metadata and record the built commit for PR #8298 * Send make_latest as the documented string for PR #8298 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Repair the release-creation tests and the publish-side guard for PR #8298 * Fail closed on promotion, order numbered prereleases and keep the pointer forwardable for PR #8298 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Resolve the newest desktop release lazily and record the updater pointer gap for PR #8298 --------- Co-authored-by: danielhanchen <danielhanchen@gmail.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> |
||
|
|
495ba21ebd
|
Studio: add MiniMax H3 video generation (#7989)
* Add MiniMax H3 video generation * Improve MiniMax H3 memory routing * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address MiniMax H3 review feedback * Keep H3's VAE off the CPU path, so low_vram stops aborting low_vram maps to the `model` policy, and offload_flags emits --vae-on-cpu for it unconditionally. On H3 that kills the process: ggml/src/ggml-cpu/ops.cpp:6321: GGML_ASSERT(src0->type == GGML_TYPE_F16) failed deterministically, SIGABRT, exit 134. Bisected on the flags: --vae-on-cpu with --audio-vae aborts, the same command without --audio-vae renders in 95.87s, and an fp16-converted audio VAE aborts too. So the trigger is the audio VAE, not the video one, and the F32 type is imposed inside stable-diffusion.cpp rather than by the file: ggml_conv_1d hardcodes an F16 im2col destination (ggml/src/ggml.c), ggml_compute_forward_im2col_f16 then asserts the KERNEL is F16, and audio_conv_weight_type (src/model/vae/ltx_audio_vae.hpp) maps only BF16 to F16 and lets F32 through. It cannot be fixed by shipping a different checkpoint. low_vram is the one mode a small-card user reaches for, so this drops the flag rather than the mode. offload_flags takes vae_on_cpu, defaulting True so no other family changes, and the H3 native path passes False. --offload-to-cpu and --clip-on-cpu still apply, which is where the saving actually is: the denoiser dominates, and with --offload-to-cpu the whole model peaks at 13.14 GiB. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Point the H3 GGUF pick at the unsloth mirror Main added test_curated_gguf_repos_are_unsloth_mirrors, which requires every curated video gguf_repo to live under unsloth/ so a one-click pick cannot 404 when a community repack is renamed or taken down. H3 was the one family still on a community repo. This was meant to be part of the merge commit but was left in the worktree, so CI on that commit still saw the old value. unsloth/MiniMax-H3-GGUF is still private and has to be made public before this merges, or the pick will 401. No CI check reads it. * Pin H3's native cfg-scale under test H3 is distilled and CFG-free: its empty unconditional prompt encodes to zero tokens, and the transposed tensor that produces trips GGML_ASSERT(!ggml_is_transposed(a)) in ggml.c. SIGABRT, exit 134. Measured: cfg 1.0 renders, cfg 1.5 and cfg 4.0 both abort. sd.cpp defaults cfg-scale to 7.0, so this is a crash a plausible refactor reintroduces by forwarding guidance the way every other family does. The native path already hardcodes 1.0 and the family sets supports_cfg = False, but nothing held either in place. supports_cfg only gates the diffusers path; the native path builds its own params. Checked the test fails when cfg_scale is changed to forward guidance, so it is not passing vacuously. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Pin diffusers by source archive instead of git, so macOS installs work The macos-15-intel leg fails deterministically, not flakily: process didn't exit successfully: `/usr/bin/git init` (exit status: 1) --- stderr xcode-select: note: No developer tools were found, requesting install. That runner has no Xcode, so /usr/bin/git is the developer-tools shim and exits 1 for everything. uv needs a working git to resolve a git+https dependency, so the diffusers pin this branch added cannot install there at all. Main is unaffected because it depends on plain diffusers. GitHub serves the same commit as a source archive, which uv installs with no git involved. Verified by stubbing git to fail exactly the way the macOS shim does: the git+ form reproduces the CI error, the archive form installs diffusers 0.40.0.dev0 from the same SHA with MiniMaxH3Transformer3DModel present and exported. Also drops a full clone of diffusers from every install. * Allow the diffusers source build on the clean-machine legs With the archive pin the macOS leg gets past `git init` and installs, but then trips the nobuild guard: built from source: diffusers -- these must resolve to wheels on a clean machine There is no wheel to resolve to. MiniMax-H3 support is not in any diffusers release, so this branch has to pin a commit, and neither a git URL nor a source archive can produce a wheel from an index. Added to the same allowlist that already carries triton-kernels for the same reason. Checked against the bar the comment there sets: diffusers builds with plain setuptools, declares no ext_modules, and its tree has zero .c/.cpp/.pyx/.rs/.cu files and no shipped binaries, so the PEP 517 build is a pure-Python copy step and needs no toolchain. The separate compiler-invocation check in both scripts is untouched and still fires if one is ever needed. Verified the allowlist logic still rejects a non-allowlisted source build (a log building both diffusers and numpy reports only numpy). Remove this entry once a diffusers release carries H3 and the requirement goes back to a version specifier. Noted in both scripts. * Baseline the hf-hub retry loop reopened by the 1.x upgrade `pip scan-packages :: hf-stack` fails on this branch with 1 unbaselined CRITICAL: C2 polling/beaconing loop detected Package: huggingface-hub File: huggingface_hub/utils/_http.py L461: while True: This is on us, not upstream drift. Main pins huggingface-hub==0.36.2; this branch needs >=1.23.0,<2.0 because diffusers at the pinned commit requires it, so the resolved version moves 0.36.2 -> 1.27.0. The baseline already carries this exact file and check at L462 and L298, from earlier versions. It did not carry over because the key hashes the matched code, not the line number, and the surrounding code changed across the major version. That is the guard behaving correctly: changed code in a baselined file reopens for review rather than staying suppressed. Reviewed it rather than just re-suppressing. L461 is the retry loop in `http_backoff`: bounded by `nb_tries > max_retries`, the URL comes from the caller, there is no hardcoded endpoint and nothing is exfiltrated. Same benign construct as the entries it replaces. Entry generated with the scanner's own _evidence_hash rather than hand-written, and inserted beside its siblings so the diff stays 8 lines. Verified: with the baseline the scan is exit 0 with 4 suppressed, without it exit 1, so the guard still bites. * Lift the macOS-arm huggingface-hub cap that this branch made unsatisfiable `mac macos-15 / trace / file` fails on this branch and passes on main. uv reports: No solution found when resolving dependencies: Because you require huggingface-hub>=0.34.0,<1.0 and huggingface-hub>=1.23.0,<2.0, your requirements are unsatisfiable. This branch moved base.txt, no-torch-runtime.txt, studio.txt and constraints.txt to hub >=1.23.0,<2.0, because diffusers at the pinned commit requires it, but left the flat <1.0 cap in overrides-darwin-arm64.txt. That file is macOS-arm only, which is why only the mac legs see it and Linux and Windows stayed green. The failure then presents as something else entirely: uv gives up, the installer falls back to pip, and the clean-machine trace fails on "installer invoked toolchain: rustc" rather than on the resolution. The cap's own comment explains it exists so the resolver can never pair hub 1.x with a pinned transformers 4.57.6 / hub 0.36.2 stack. That is still true below python 3.10 and still capped there. At 3.10 and above this branch is on transformers 5.5.0 and hub 1.x, so the premise is gone, and mlx-audio's own >=1.0 floor is satisfied by the 1.x window anyway. Checked by collecting every hub specifier that applies per Python version across all five files: py3.9 resolves to 0.36.2 as intended, py3.13 to 1.23.0/1.27.0. Before this change py3.13 resolved to nothing. * Make H3's native download use the repo the family advertises The curated-mirror test main added only inspects VideoFamily.gguf_repo. H3's native path does not read that field: video_minimax_h3.py has its own H3_GGUF_REPO constant, used for both the transformer and the Qwen3-VL encoder. So pointing the family at the unsloth mirror in the previous commit satisfied that test while the actual one-click download still came from a community repack, which is the exact failure the test exists to prevent. Pointed the constant at the same mirror and added test_the_h3_native_repo_matches_the_family_gguf_repo to pin the pair, so the two cannot drift apart again. Verified it fails when the constant is put back to leejet, so it is not passing vacuously. The mirror now carries the Qwen3-VL encoder quants alongside the denoisers, byte-identical in size to the community ones, so this repo alone satisfies both of h3_native_hub_files' hub entries. The encoder is part of MiniMaxAI/MiniMax-H3 itself (FL2VA/text_encoder) which we already mirror publicly at unsloth/MiniMax-H3 under the same licence, so shipping a quantization of it beside the denoisers is the same act. Also checked the encoder-tier routing survives the dynamic rung names: -UD-Q2_K_XL selects the Q2_K_M encoder and -UD-Q3_K_XL the Q4_K_M one, asserted in the new test. Updated the download-plan test, which hardcoded the old repo id. NOTE: unsloth/MiniMax-H3-GGUF is private. Unlike before, that now really does gate this: the native path downloads from it. It has to be public before this merges. * Pin H3's companion-checkpoint guard under test validate_h3_transformer_filename had no test. That mattered less when the denoisers lived alone; the mirror now ships the Qwen3-VL encoder quants in the same repo, so the picker lists both and a user can name either. Loading a 12-17 GB encoder as the transformer would fail deep inside sd-cli instead of at the boundary. The accept cases include the dynamic rung names on purpose. The guard is a prefix/suffix check and `-UD-Q2_K_XL` is a shape it had never seen when it was written; it happens to pass, and now that is asserted rather than assumed. Checked the test fails when the prefix check is dropped, so it is not vacuous. * Record why H3 drops --vae-on-cpu, now that the abort is fixed The comment justified the drop entirely by an sd.cpp abort. That abort is fixed in the Unsloth fork, which would have made this look like a stale workaround to revert once the fix reaches the pinned prebuilt. Measured on a build carrying the fix, 640x384, 25 frames, 4 steps, q4_K, with --offload-to-cpu --clip-on-cpu already applied: adding --vae-on-cpu moved peak VRAM 12.42 -> 12.42 GiB and wall time 20.9s -> 100.4s. Under --offload-to-cpu the peak is set by the streamed denoiser, so the flag saves nothing and costs 4.8x. It stays off on its own merits. * Pin the sd.cpp prebuilt that actually renders MiniMax-H3 The pin was master-812-ea7f0c8, a stock upstream build, and on a stock build H3 does not work: it aborts on the default --cfg-scale, aborts again on --vae-on-cpu, and its 1-D norms are quantized into an output uncorrelated with its own bf16 reference (LPIPS 0.981). The Studio side worked around the first by pinning cfg to 1.0 and the second by dropping the flag; the third had no workaround on the consumer side at all. All three are fixed in unslothai/stable-diffusion.cpp and open upstream as leejet/stable-diffusion.cpp#1861, #1862 and #1863. The mirror's prebuilt pipeline now applies them on top of the aged upstream tag it already builds, and marks such a build with a -u<id> suffix naming the patch set, so master-813-bfbef5b-u0665242 is upstream master-813-bfbef5b plus those three patches and nothing else. The patches are deleted once upstream releases them, at which point this pin goes back to a plain tag. Verified on the published Linux x86_64 asset, not on a local build: both new error strings are in the shipped sd-cli, and running it on a q4_K H3 denoiser without --mode vid_gen now exits 1 with the instruction instead of core dumping on a ggml assert. test_video_backend's fake engine returned the old tag as its version string, which read like a second pin; it only needs a non-None value, so it now says so. * Close two gaps the H3 mirror switch opened Both are consequences of the two preceding commits, found in review. The prebuilt pin is now mirror-only (master-813-bfbef5b-u0665242), and _resolve_with_fallback still asked leejet for it. That request is a guaranteed 404 by construction, since the -u<id> suffix marks a build only the mirror makes, so it was a wasted round trip on every install. Worse, when the mirror genuinely cannot serve a host the fallback lands on leejet's latest, which has none of the H3 fixes. For every other model that is the right trade, better a stock native engine than none. For H3 it is not visible: it aborts on the default cfg-scale, aborts on --vae-on-cpu, and a blanket --type renders a broken video rather than failing. A user who saw only the generic 'falling back to leejet' line had nothing connecting that to the output. It now says so. Second, hub/utils/gguf.py filtered H3 companion GGUFs by the old community repo id only. The mirror the family and catalog now advertise carries the Qwen3-VL encoder quants beside the denoisers, so a 12 GB text encoder was being aggregated as if it were a selectable transformer quant. Both bundle repos are now recognised, case-insensitively, and the cache-dir match follows. Tests are mutation-verified rather than assumed: restoring the upstream 404 attempt fails the ordering test, removing the H3 warning fails the fallback test, and dropping the mirror from the bundle set fails three. The ordering test deliberately makes the mirror serve nothing, because with the mirror serving the first attempt succeeds and the upstream attempts are never reached, which made an earlier version of it pass under its own mutation. A third test pins the native loader's H3_GGUF_REPO to the bundle set, since those live in different files and a future repo move that updated only the loader would silently reintroduce the same aggregation bug. * Exclude MiniMax-H3's small-M projections from int8 _INT8_FAMILY_EXCLUDE_NAME_TOKENS has entries for qwen-image and hunyuanvideo-1.5 but none for minimax-h3, and H3 needs one for the same reason they do. H3's adaLN projection is named adaln_proj, which no token in the generic list matches: 'norm' is the closest and does not appear in the name. On the dense checkpoint that projection is Linear(2688 -> 96768), so it clears min_features = 512, gets quantized, and then runs at M = 1. Inductor lowers int8 matmul to _int_mm, which requires M > 16, so it raises 'self.size(0) needs to be greater than 16, but got 1' at the first denoise. The offline prequant builder bakes it in happily, which is exactly the drift the exclude list exists to prevent for Flux and Qwen. The pruned-modulation form hides this rather than fixing it: there adaln_proj is Linear(8 -> 96768) and falls under min_features anyway. So this exclusion is what makes the DENSE path correct and is a no-op on the pruned one. context_embedder and token_refiner are added for the same reason hunyuanvideo-1.5 excludes its text stream. Measured at M = 10 text tokens against the video stream's thousands, they are 3.47% of GEMM time even in the slow eager int8 path, so leaving them bf16 costs nothing measurable. This is what made an earlier measurement conclude int8 does not work on H3. It does: on the pruned form int8 compiles and is 4.24% +-0.54% faster than fp8 at identical memory, paired over 12 renders. * Say which H3 component could not be downloaded, and why H3 pulls four files from two repos, and the Hub returns the same 'Repository Not Found ... make sure you are authenticated' for a repo that does not exist, one that is private, and one your token does not cover. A user reading that has no way to tell which of the four failed, and the wording points away from the real cause whenever the repo exists but is not public. That is the state the GGUF mirror is in today: it is unpublished, so picking H3 fails with a message suggesting the user fix their token, which will not help. This replaces it with the repo, the component, and the actual remedy, and says the other components are unaffected so the failure is not read as total. Gated repos get different wording, since accepting a licence is a different action from waiting for a repo to be published. Anything that is not a recognised access error is passed back unchanged rather than reworded, so a timeout or a full disk still reads as itself. The helper returns the exception instead of raising, so the caller keeps raise-from and the original traceback survives. Mutation-verified three ways: rewording every error (timeouts included), fixing the component name to 'denoiser', and giving gated repos the private wording each fail the test. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Stop MiniMax-H3 holding two copies of its video VAE Two thirds of an H3 render's peak is not activations. Measured at 640x384 across 124 frames, a 20.25 GB int8 denoiser peaks at 36.96 GB, and the gap is almost all weights: the video VAE alone is 10.42 GB because diffusers pins it to float32, and a further 4.91 GB is autocast's own float16 copy of those same weights. A memory snapshot puts 92.9% of the transient in 437 blocks allocated from nn.Linear, largest 67.1 MB, which is the decoder's [2048, 16384] SwiGLU projection in float16. MiniMaxH3VideoDecodeStep wraps vae.decode in torch.autocast(float16), and autocast caches every weight it casts for the lifetime of the region, so the float32 original and its float16 twin are both resident through the whole decode. Storing those weights as float16 up front makes the cast a no-op and removes both. This is not an approximation: x.to(float16).to(float16) is x.to(float16), and the four regression tests check that on a real matmul rather than on the reasoning. The audio VAE decode is not under autocast, so it keeps float32. t2va starts from noise and never encodes, so vae.encoder and vae.quant_conv go too. That part is gated on the workflow name rather than dropped unconditionally, because an image-conditioned workflow needs them. Measured 36.96 -> 28.37 GB peak with the encoder drop and the pre-cast, 28.27 with expandable_segments as well, over 5 prompts x 2 seeds. Speed is unchanged (-0.05% +-0.86% eager, -0.34% +-2.23% compiled), and every arm hashes identically to its control on latents, audio_latents, frames and audio. The estimator's base still reads 68.5 GB. That figure was measured on the bfloat16 modular components, not the int8 arm above, so it stays put until it is re-measured in the same configuration rather than adjusted by arithmetic. * Pad MiniMax-H3's small-M int8 linears instead of leaving them dense torch._int_mm asserts self.size(0) > 16. torchao's eager path never trips it (safe_int_mm falls back to a widened matmul), but inductor lowers the same quantized linear straight to _int_mm, so any quantized Linear invoked at a small activation row count crashes under torch.compile. Until now the fix was to leave those linears dense bf16, which on H3 meant excluding context_embedder and both token_refiner blocks: 13 linears, 798M parameters, 0.80 GB of weights the int8 checkpoint was not allowed to touch. Pad instead. diffusion_quant_pad.PadToMinM pads the flattened row count up to 32, runs the GEMM and slices the result back, so the module compiles with no change to the quantization config and the caller's rows come back bitwise unchanged. Verified bitwise on all 65 (module, M) cases across H3's 13 linears at M = 10, 13, 14, 17, 19, on real torchao-quantized weights; compiling those same modules unpadded raises the _int_mm assert at M = 10, 13 and 14. Two properties carry that exactness and both are asserted rather than assumed. The pad rows replicate row 0, not zeros: an all-zero row has amax 0, so the activation quantizer divides by zero. And the activation scale must be per row, so each kept row's scale comes from that row alone; a quantized Linear whose granularity cannot be proven per row raises instead of being quietly skipped, because a half-padded transformer compiles on the wrapped modules and crashes on the rest. Everything below pad_to normalises to pad_to rather than only what is below the floor, so one inductor graph covers every prompt length in the range. H3's seven eval prompts run at M = 10..19, which straddles the floor, and padding only to 17 would leave three shapes behind. The wrapper reparents the Linear, so it runs after quantize_ on the runtime path and after load_state_dict on the prequant one. The offline builder drives quantize_ directly and saves the state dict, so it never sees a wrapper; PadToMinM is also state-dict transparent as a second line of defence, saving and loading under its own prefix so a wrapped transformer still writes context_embedder.weight. Scoped to minimax-h3. qwen-image, qwen-image-edit and hunyuanvideo-1.5 have the same small-M shape but published int8 prequant checkpoints whose metadata bakes the current exclusion set, and _validate_checkpoint compares that set against exclude_tokens_for_scheme, so they move only together with a rebuild. adaln_proj stays excluded for a different reason: on the dense checkpoint it is Linear(2688 -> 96768) and runs at M = 1, while on the pruned form it is Linear(8 -> 96768) and falls under min_features anyway (verified: the filter rejects all 51 for min_features). Measured on B200, 640x384 x 124 frames, 4 steps, 7 prompts x 2 seeds, the two arms alternated within each cell so drift on a shared box cancels: checkpoint 21.052 -> 20.254 GB (-0.798, -3.8%) transformer 21.051 -> 20.253 GB (-0.798) load peak 21.137 -> 20.336 GB (-0.801) render peak 37.766 -> 36.966 GB (-0.800, -2.1%) step time +0.0597 s +-0.0057 eager, +0.0533 s +-0.0086 compiled The time is torchao's un-fused eager activation quantization on 13 modules that run ONCE per render, so it is a fixed cost rather than a per-step one, and it does not scale with steps or resolution. Compiling those modules alongside the blocks removes it: +0.0034 s +-0.0040, no detectable difference. Quality is unchanged as far as n = 14 can resolve: against the same bf16 twin the padded arm sits +0.0070 +-0.0151 LPIPS from the excluded one, which rules out a degradation larger than 0.022. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Classify the MiniMax-H3 GGUF bundle as video in the cached inventory The H3 GGUFs are stable-diffusion.cpp conversions and carry no metadata keys at all (kv_count 0), so general.architecture is absent where the LTX-2 and Wan video GGUFs declare ltxv/wan. _arch_to_task therefore left the downloaded repo with no task, which drops it from the Video picker's On Device list and hands it to chat as a plain GGUF. Key the two bundle repo ids before the arch is consulted. * Release the VIDEO GPU claim when H3 native falls back to the CPU build On a CUDA/ROCm host /video/load acquires the VIDEO arbiter owner because the resolved device target is not CPU. _run_load_h3_native then asks for an accelerator-matched sd-cli, and the pinned prebuilt release publishes no Linux CUDA/ROCm archive, so ensure_sd_cpp_binary returns None and the load commits the CPU build with native_device = cpu. Nothing dropped the VIDEO claim, so the next chat or image acquire evicted and unloaded an H3 runtime that holds no VRAM. Release the claim once the CPU fallback is committed, through release_if so the token check is atomic against a newer load that already took ownership. Mirrors the CPU-only native release /images/load already does. * Video: protect the native H3 companion repos, cancel the modular denoise, forward the hub token - VideoBackend.loaded_repo_ids() publishes the repos the committed native H3 runtime re-reads every generation (Qwen encoder + both VAEs), and the delete-cached guard consults it, so deleting a companion under a loaded model is refused. - The H3 modular workflow no longer falls back to a null progress context: the denoise loop drives pipe.scheduler.step once per step, so the existing wrapper gives it the same per-step progress and cancellation the other callback-less pipeline gets. - load_components() gets the Settings token, so gated/private component loads are not issued anonymously. * Reject stable-diffusion.cpp builds that predate MiniMax-H3 support ensure_sd_cpp_binary hands back whatever find_sd_cpp_binary locates and only probes that it runs, so an install upgraded from an older Studio kept serving its pre-H3 managed sd-cli. The H3 load's only gate is SdCppEngine.version(), which that binary passes, so the load reported ready and the failure surfaced on the first generation, after the whole bundle had downloaded. Gate the H3 path alone on the capability instead of a version string: upstream added --ref-video and the other H3-only options in the same commit that added MiniMax-H3 (leejet/stable-diffusion.cpp#1854, master-812-ea7f0c8), and the release prebuilts report 'version unknown, commit unknown' because they are built without a .git directory, so --help is the only usable signal. Image generation keeps accepting any user-supplied build. A stale copy under the installer-owned root is removed so the pinned prebuilt reinstalls; a build the user supplied is left in place and the load fails naming it, the same ownership split _usable_or_discard_managed makes. A --help that cannot be read means 'cannot tell', never 'no H3'. * Distinguish a reused CPU sd-cli from an accelerator build on an H3 load On a Linux CUDA host the first H3 load installs the CPU prebuilt through the fallback and correctly commits native_device = cpu, because the pinned tag publishes no Linux CUDA, ROCm or Vulkan asset. Every later load then calls ensure_sd_cpp_binary(accelerator = cuda), which finds that same CPU binary and returns it without looking at what it was built for, so the fallback was skipped, native_device stayed cuda, and Studio applied GPU offload policy and retained the VIDEO gpu_arbiter claim while sd-cli ran wholly on the CPU. A later chat or image acquire then evicted an unrelated GPU model. Fall back on what the binary offers rather than on whether one was returned: sd-cli --list-devices prints one name/description line per available ggml backend device, so a CPU-only build answers with CPU alone. The second load now reaches the same cpu conclusion as the first, which is what lets the existing release_if drop the stale claim. Unreadable output, or an older build that rejects the flag, keeps the GPU: neither says the accelerator is missing. * Guard the H3 companion repos while a native video load is downloading _run_load_h3_native downloads from repo_id, the H3 GGUF companion and the H3 component repo, but the in-flight state only carried repo_id and base_repo. The cached-model delete guard reads loading_repo_ids(), so it allowed deleting Comfy-Org/MiniMax-H3, and the GGUF companion when the load comes from another mirror or a local file, while those files were still downloading, which fails the load. Carry the companions on the loading state, the way the image backend's _SdLoading already does, and publish them from loading_repo_ids(). This is the in-flight twin of loaded_repo_ids() and covers the same repos. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Keep curated Recommended rows searchable and give them their metadata Two things went wrong with the curated video models the Recommended list paints from the catalog rather than from a live Hub listing. Search dropped them. The Recommended search matched the query against `recommendedIds`, which filters out every id already on disk because a downloaded model gets its own On Device row. The unfiltered Recommended list does not filter that way: it renders the curated seeds and badges the downloaded ones. So a curated model was visible in the list and unfindable by typing its name the moment it was downloaded, and only a live listing row could bring it back, which a repo the listing does not return never gets. `searchableRecommendedIds` unions the seed ids with the listing ids, seeds first, deduped case insensitively (the HF cache lowercases repo ids), so both lists agree on what exists. Rows rendered bare. Everything past the id came from the listing alone, so a curated row the listing never returns showed no parameter chip and no capability glyph while its neighbours showed both. The catalog now carries the two facts nothing else can supply: `totalParams` per artifact and `capabilities` per group, read through `curatedTotalParamsFor` and `curatedCapabilitiesFor`. Both are fallbacks only. A listing row wins wherever there is one, because real tags and a Hub-reported total outrank anything hand written here. The parameter counts are measured, not guessed. The MiniMax-H3 figure is the sum of the tensor shapes in the BF16 GGUF of the FL2VA denoiser that repo publishes; the LTX-2.3 figure is what the Hub reports for its repo, carried so the row looks the same offline or rate limited. One more inconsistency fell out of the same place: `searchRowFits` hides anything it cannot size (`requireKnown`), and it could not size a curated repo with no listing row and no "<n>B" token in its id, so turning on "Fits on device" hid from search a row the unfiltered list still showed. It now falls back to the curated total the same way it already fell back to the curated size. Covered by studio/frontend/tests/recommended-curated-row-metadata.test.ts: 15 assertions over the search pool, the two catalog lookups, the fit check, and the four picker call sites that read them. * Load MiniMax-H3 from a hosted pre-quantized denoiser Video families had no way to reach a hosted pre-quantized DENOISER. VideoFamily carried gguf_repo and te_prequant_repos (the text encoder) only, and nothing in video.py consulted a denoiser table, so a MiniMax-H3 load either pulled the full 66.3 GB bfloat16 DiT or nothing at all. The image side already solves this with DiffusionFamily.prequant_repos plus family_prequant_repo(), so this follows that shape rather than inventing a second one. Four parts. VideoFamily gains prequant_repos, prequant_variant_repos and prequant_subfolder, resolved by video_family_prequant_repo() / video_family_prequant_schemes(), mirroring the image resolver. The registries stay separate, as the module header requires, so the base-id normaliser is local rather than imported. The shared resolver learns an optional subfolder. The hosted video checkpoints nest theirs one level down instead of keeping it at the repo root, and the prefix has to reach BOTH candidate names or the primary 404 is followed by a second one and the load silently falls back to the dense download. Always a literal forward slash: these are Hub repo paths, and a Windows join would miss the cache. The cache and download plumbing already handled a nested name, so only the filename builder changed, and every existing call site is byte-identical. The modular workflow builds its denoiser through its own component loader, so there is no dense module to quantise in place. A hosted checkpoint is therefore the only way to run that transformer quantized, and pre-seeding it with update_components() before load_components() is also what stops the dense download: load_components(names=None) skips a component whose attribute is already set. Passing names= instead would have forfeited the workflow's own block pruning, which is what already avoids the 61.7 GB Ref2VA transformer. Those checkpoints carry the pruned adaLN, where the modulation is a rank-8 affine factorization of the time-embedding curve plus a shared table, and roughly 40% of the released model's parameters go. Against the base repo's dense config the model is four keys short, one over and fifty-one shapes wrong, so the strict load fails and the checkpoint is unloadable by every route. video_minimax_h3_adaln.py reshapes the model between from_config and load_state_dict: table lookup with interpolation instead of the timestep MLP, no SiLU (the table already holds the activation's own output projected onto the basis), and the modulation cast to the block stack's dtype, without which the first quantized matmul dies on mismatched dtypes. Bound per instance, so a dense load in the same process is untouched. Finally the refusal becomes honest. A single-file pick on a modular family used to reach the loader only after ~98.7 GB had downloaded AND after the resident pipeline had been evicted to make room for it, because download-plan returned 200 and validation passed. Both refusals now run in validate_load_request, ahead of the diffusers availability probe so they still fire where diffusers cannot be imported, and each names what to pick instead. download-plan forwards transformer_quant to validation and to the plan, without which the quant-keyed refusal never fires on the route that stages the download and the plan stages shards the load never opens. * Point both MiniMax-H3 schemes at one hosted pre-quantized repo The two hosted pre-quantized denoisers were split across two repos with the checkpoint nested one level down, so reaching them needed a mechanism the image side has never had: a VideoFamily.prequant_subfolder field, a prequant_subfolder_prefix() normaliser, and a subfolder keyword threaded through both resolve_prequant_source() and usable_prequant_source(). Both schemes now live in ONE hosted repo, at the root, named <Model>-<SCHEME>.pt. That is the layout every image-side prequant repo already uses, and it is exactly what prequant_repo_filename() builds unaided, so the whole mechanism goes. Match an existing convention and the code should shrink, not grow: -22 lines in diffusion_prequant.py, -6 in video_families.py, -6 in video.py, with no new concept to carry. Landing on the primary name also fixes a memory-planning under-credit. cached_checkpoint_path() deliberately credits only the PRIMARY filename, so that a cached legacy artifact cannot pin a stale name once a repo ships the real one. While these checkpoints were published as transformer_<scheme>.pt the primary never existed: every hit came through fallback_filename, and planning therefore read an already-cached checkpoint as "this would have to download" and handed the pick to GGUF. The primary is now the published name, so the probe hits it. fallback_filename stays. It still covers repos that have not been renamed, and dropping it is a separate decision from this one. Tests: the subfolder-prefix cases are replaced by the naming they now guard -- both schemes resolving to one repo, the primary resolving to a root-level <Model>-<SCHEME>.pt with no directory component on any platform, the cache probe being asked for that primary name, and the repo's own scheme suffix being stripped and replaced rather than carried through. Five mutations run, each caught by the named test and reverted: two repos again (M1), the primary nested under prequant/ again (M2), the suffix strip removed (M3), the cache probe keyed on the fallback (M4), the fallback name dropped (M5). * Add MiniMax-H3 image and reference video conditioning * Improve H3 finalization progress * Report real sd.cpp progress on the Video page instead of a frozen 0 of 30 A native (GGUF) video generation reported phase "denoise", step 0/30 for its entire run and then flipped straight to "completed". Two separate things were wrong, and the progress endpoint could not move until both were fixed. The parser looked for r"(?:step|sampling)\D+(\d+)/(\d+)". sd-cli's sampling bar contains neither word. It prints |=========> | 7/30 - 21.50s/it so nothing ever matched. Anchor the pattern on the bar and on the trailing speed unit instead. A bare "n/m" is deliberately not enough: an unrelated ratio in some other log line must not drive the progress bar. The reader also delivered every redraw one step late. sd-cli redraws in place, and its carriage return LEADS the record while the newline only arrives on the final step, so a reader keyed on CR/LF cannot produce step 1 until step 2 has been flushed. Treat the erase-to-end-of-line that closes each redraw as a terminator too, and read the pipe with buffer.read1 so a record that carries no newline is not stuck behind a blocking readline. Escapes are stripped before a record reaches on_log or the error tail. Streams without a raw .buffer keep the old line iteration. The same bar shape is printed by three different things, so the parser now tells them apart rather than reporting whichever came last. Weight load prints it with a byte rate, and tiled VAE decode prints an identical s/it bar counting TILES: without a guard a run finished sampling at 30/30 and then jumped backwards to "step 1/16". Load and decode are real work with no sampling step, so they report their own phase and a null step rather than a fake 0 of 30. ETA is measured from the first step, not from job start, so the one-off weight load is not charged to every remaining step. Verified end to end against a real CUDA sd-cli MiniMax-H3 generation: the step advances 1..6 over 6.2s..9.7s of wall clock, load and decode are reported as themselves, and the ETA tracks. * Read sd-cli's in-place progress redraws so the Video bar moves during sampling The native H3 progress bar had two independent causes and fixing either alone changed nothing observable. The bar pattern is now correct, but the reader still was not. sd-cli redraws its sampling bar in place: one printf per step shaped "\r<bar> <n>/<total> - <speed>\033[K", with a newline only on the final step of a phase. The drain loop did `for raw in proc.stdout`, which terminates on LF only, so every redraw sat in the buffer until the next one's carriage return arrived and the last one until sampling was already over. The Video page saw nothing. Split the raw pipe into records on CR, LF, or the trailing erase-to-end-of-line, reading through buffer.read1 with an incremental UTF-8 decoder so a multi-byte character straddling two reads survives, and strip the CSI escapes before the record reaches on_log or the error tail. Streams without a raw .buffer (test doubles, non-pipes) fall back to line iteration. The new backend test drives the real byte stream through both halves, one flush per read, and asserts each step is visible on the read that carried it rather than one redraw later. * Give MiniMax-H3 first and last frame conditioning in the video backend MiniMax-H3's released transformer is the FL2VA one: text-to-video is the same checkpoint run with no keyframes. Studio only ever ran it text-only, so the Video generate request had nowhere to attach a reference frame. The load used to prune the block graph to t2va. That argument prunes STATICALLY, so an fl2va-pruned pipeline runs the keyframe blocks on every request and cannot serve a text-only one at all: it raises packing an empty conditioning list. The load now keeps the whole auto graph, which selects per request, and bounds load_components to the keyframe workflow's component set instead, so the 61.7 GB Ref2VA partition is no more loaded than before. Measured against the released checkpoint: a text-only request through this pipeline is bit-identical, video and audio, to the same request through a t2va-pruned one. A keyframe is a geometry anchor, not just conditioning, so the canvas comes from its aspect ratio through the released arithmetic (768 short edge, area capped at 768x1344, both axes rounded to 32) rather than from whatever resolution preset was selected. An arbitrary size produces a garbled clip rather than an error. sd.cpp already implements the same conditioning, so that side is the existing --init-img / --end-img flags with the frames staged as PNGs. Only MiniMax-H3 declares the capability, and status reports it, so Wan and LTX do not grow a control that does nothing. * Cover the MiniMax-H3 keyframe path with tests Registry (which families declare it, and the canvas a keyframe resolves to), the load wiring (whole block graph, component set still bounded, VAE encoder kept), request handling (decode, refusal, canvas override, what reaches the pipeline call) and the sd-cli argv. * Add the reference-frame controls to the Video page First and last frame pickers, shown only for a family whose status declares keyframe conditioning, so Wan and LTX are unchanged. The Images page's source picker moves to a shared component rather than the Video page growing a second upload path; both send the same data URL to the same backend decoder. While a frame is attached the Resolution preset is disabled and says why: the frame's aspect ratio resolves the clip's size, the way the model itself does. The gallery recipe records which ends were pinned. * Check the keyframe canvas against the pipeline's own resolver The canvas rule is a checkpoint contract, so pin it to the released implementation rather than only to hand-written expectations. Skipped where diffusers does not ship MiniMax-H3, which is most runners. * Revert the standalone H3 keyframe implementation oobabooga/unsloth#121 covers first frame, last frame, first-and-last, Ref2VA and the canvas rule, and it reached the same load construction independently. Two implementations of the same feature on one branch is worse than either, so this takes mine back out and leaves the branch ready for that work to land whole. The one finding worth keeping from it is already reflected there: passing workflow= to ModularPipeline.from_pretrained prunes the block graph statically, so the pipeline must be built unpruned and only load_components bounded. * Keep the pre-quantized MiniMax-H3 denoiser resident so a generation can run Loading H3 with a hosted pre-quantized denoiser worked, but every generation died on its first denoise step: Attempted to set the storage of a tensor on device "cuda:0" to a storage on different device "cpu". This is no longer allowed; the devices must match. ComponentsManager.enable_auto_cpu_offload parks every component on the CPU and moves each one onto the accelerator inside its own pre_forward, that is from within the block that is already executing. The text encoder and the VAEs survive that; a torchao-quantized denoiser does not, because the device change reaches return_and_correct_aliasing, which tries to alias a CPU storage to an accelerator tensor. Moving the same module at load time, outside any executing block, works. So place it once at load and take it out of the offload rotation: drop its hook, unlist it from the other components' eviction candidates, and move it. Everything else is unchanged, and the encoder and VAEs still offload around it. Keeping it resident is what asking for a quantized denoiser buys in the first place: the hosted checkpoint is about 20 GB against 66.3 GB dense. Verified end to end on a B200: MiniMaxAI/MiniMax-H3 loaded with the hosted fp8 denoiser, then a 1280x768, 124-frame clip generated from a start frame in 167s. The clip's first frame matches the supplied image and the motion is coherent. * Apply the pinned Diffusers revision on a fresh install, not just an update MiniMax-H3 needs a Diffusers revision newer than any published release, and Studio refuses to load it otherwise. The pin was in studio/backend/requirements/base.txt, and a clean install.sh run still ended up on diffusers 0.39.0 from PyPI, every time, with nothing in the log to say so. base.txt is never installed by install.sh. install.sh installs unsloth itself, whose own metadata pulls a diffusers release in transitively, and then runs install_python_stack.py with SKIP_STUDIO_BASE=1, where the base-packages step is a bare `pass`. So the pin applied on `unsloth studio update` and on the no-torch path (install.sh installs no-torch-runtime.txt directly) and was dead on exactly the path a new user takes. Reproduced on a clean install into a throwaway prefix before and after: 0.39.0, then 0.40.0.dev0 with MiniMaxH3Transformer3DModel present. The revision now lives in its own diffusers-pin.txt, installed by a step that sits outside every skip_base / NO_TORCH branch and after every other requirements file, so nothing left in the run can re-resolve diffusers back to a release behind it. No forced reinstall is needed: a direct URL requirement is not satisfied by a resident registry install, so the step is a no-op once the environment is already on the pin. tests/studio/install/test_diffusers_pin.py holds the shape in place: exactly one requirements file may name diffusers, the pin must be a full commit sha rather than a moving ref, the install step must sit at function top level rather than under a conditional, and it must come after every other requirements install. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: let the Video and Images pickers see a local diffusers pipeline A model already on disk never reached the pickers' On Device list unless it happened to keep a weight file beside a root config.json. Every image and video model downloaded as a pipeline keeps its weights in component subdirs under a root model_index.json instead, so the hub inventory scan behind /api/hub/local rejected it. Pointing Custom Folders at one was worse than empty: the LM Studio publisher walk descended into the pipeline and offered vae, transformer, text_encoder and audio_vae as four separate models, none of them loadable. Teach the hub scanner the same pipeline-root test routes/models.py already applies, in the three places that only accepted a root config plus loose weights, and keep a pipeline row through the custom-folder format filter: the layout has no loose weight to classify, so the row is "unknown" by construction rather than by fault. * Cover the local diffusers pipeline scan with tests * Pin what the pipeline exemption must not let through Three gaps in the cover added with the scan change, each found by mutating the fix and watching the suite stay green. The custom-folder format filter now waves a row through on its shape, and nothing said what it still has to reject: replacing the whole predicate with True passed. A folder holding a config.json and no weights, which an aborted download leaves behind, reports the same "unknown" format and no loader can start it, so it pins the boundary. The predicate is applied to every row the filter did not already accept, and a row's path can be a GGUF file rather than a directory. A missing path, a file, and a directory whose model_index.json is itself a directory must answer False rather than raise, because an exception there fails the scan and empties the picker. The publisher walk was only covered one level up. Adding the model folder itself as a scan folder is the obvious thing to do and used to publish vae, transformer and text_encoder as three models. * Drop the unused H3_TASK_KEYFRAMES import from the video backend video.py only branches on H3_TASK_REFERENCES; the keyframe constant is read from video_minimax_h3 directly by the tests that need it. The hoisted-import safety net in Source lint flags the unused name as a blocker. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Stage the hosted pre-quantized H3 denoiser in the video download plan The plan already drops the dense transformer shards whenever a hosted pre-quantized checkpoint covers them, but nothing put that checkpoint back: an int8 or fp8 H3 stage skipped 66.3 GB of base shards and added none of the 20.25 GB artifact the load actually opens. The byte total under-reported the stage by the size of the checkpoint, the disk preflight cleared a volume that could not hold it, and an offline stage completed without the one file the load needs. _denoiser_prequant_hub_files mirrors the pre-cast encoder helper: it resolves the family's hosted checkpoint, confirms the file really exists on the Hub, and prefers the repo-root name over the legacy scheme name in the same order the load tries them. An unreachable repo is logged and yields no files, so a gated or renamed artifact keeps the dense shards instead of sinking the plan. The progress-bar estimate is deliberately left alone: it counts cached bytes for the checkpoint and base repos only, so adding a third repo there would leave the bar permanently short of 100 percent. * List both H3 denoiser partitions in the picker, not only FL2VA The bundle-repo filter accepted only minimax_h3_fl2va*, so every published minimax_h3_ref2va* quant was hidden from the variant listing. The loader disagrees: validate_h3_transformer_filename accepts either partition, on the grounds that which one is picked IS the task, and h3_transformer_task routes Ref2VA to the reference-video workflow this PR adds. The community bundle repo publishes three Ref2VA quants today, so the reference path was unreachable from the remote catalog. Accept both prefixes from one shared tuple and keep excluding the Qwen3-VL encoder and VAE companions, which are never picks for either partition. The filter test asserted the old behaviour and is updated with it. * Bound H3 reference-audio decoding to the trained window The reference-video decoder already selects, resizes and refuses incrementally because the encoded size says nothing about the decoded size. The audio decoder did not: it appended every resampled block to a list and then allocated a second full buffer in np.concatenate, with no duration or sample cap. The route accepts 32 MiB of encoded audio, which is over half an hour of compressed stereo. That lands as roughly 1.9 GB of float32 and doubles again in the concatenate, and up to three references are accepted per request, so an ordinary long music or podcast file picked by mistake could exhaust the host before the background job even started. H3's reference window is 15 seconds, so anything past it is unusable rather than merely large. Refuse it while decoding, with the same shape of message the video guard uses, instead of decoding it first. * Refuse a quantized H3 reference load instead of seeding the keyframe denoiser The hosted pre-quantized checkpoints are FL2VA (keyframe) denoisers. Ref2VA shares their module shapes and the same base model, so resolve_prequant_source handed one back for a reference load, it passed every metadata check, and seeding it made load_components skip the real Ref2VA transformer. The request then generated from the wrong partition rather than failing, which is the worst of the three outcomes. The route accepts h3_task, so this was reachable from the public API even though the picker does not expose the choice yet. validate_load_request now refuses the pairing with a message naming the workable alternatives, in the same place it already refuses a scheme with no hosted checkpoint, and the modular loader drops to the released components if a direct call reaches it. Nothing changes for keyframe loads, which are what the checkpoints are. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Skip modular-workflow families in the dense text-encoder plan assertion The plan-unchanged sweep from main walks every family and asserts the dense budget plan_diffusion_memory received. MiniMax-H3 is the first modular-workflow family to reach that list, and load_pipeline dispatches to the workflow's own loader before the planner runs: each component is built by its own from_pretrained, so there is no single dense pipeline to budget and no plan call to assert on. Skip it the way the sweep already skips wan2.2-t2v-a14b. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Scope the paired-axes canvas rule to keyframe requests A half-specified width/height is long-standing, documented API behaviour: the backend resolves the missing axis from the family's default preset, in both validate_video_request_shape and _resolve_keyframes. Applying the new paired-axes rule as an unconditional request validator rejected those calls with a 422 before family validation ever ran, breaking existing LTX, Wan, Hunyuan and prompt-only H3 clients. The rule still holds where it means something: with a keyframe present the canvas is matched to the source aspect whenever either axis is missing, so the axis the caller sent would be silently discarded. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Keep the CUDA sd-cli pin and translate a mirror-only tag upstream The merge with main brings in the accelerator-aware installer, whose fallback translates a mirror-only -u<id> pin back to the upstream release it was built from instead of skipping the upstream attempt. That is strictly better: skipping kept the round trip cheap but dropped the pin entirely on every host the mirror does not build, leaving them on upstream latest. test_a_mirror_only_pin_is_never_requested_upstream asserted the old shape, that the fallback settled for upstream latest. It now pins the new one: never the literal -u<id> string, the translated release instead, and no latest attempt at all because the translated pin succeeds. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han <danielhanchen@gmail.com> |
||
|
|
b3705f44b9
|
Studio: add a loaded models indicator with per-model eject (#8082)
* Studio: add a loaded models indicator with per-model eject
A loaded model is only visible on the page that loaded it. Leave Chat for the
Hub or Train and nothing on screen says a model is still holding memory, so the
only way to check or release it is to navigate back.
Adds a compact card in the bottom-right listing everything resident, with an
eject button per row. Chat, images, video and dictation each own a runtime and
their own /status, so all four are read and merged. A TTS load is split out from
a chat load by audio_type: same picker, but it does not answer prompts.
It joins the existing bottom-right stack in provider.tsx alongside the update
banners and the download panel, so nothing overlaps. Collapses to a pill, and
that choice persists. On by default, with a toggle in Settings, General,
Notifications.
Reads fail soft per runtime, so a chat-only host missing /video keeps its other
rows. Ejecting the active chat model reuses the API monitor's read, unload,
re-read sequence, since an API auto-switch can replace the model mid-eject.
* Studio: address review on the loaded models indicator
Eject the row's own model, not whatever is active. An API auto-switch can land
between the poll and the click, and re-reading unconditionally would free a
model nobody asked to free while reporting the clicked one as ejected. The
sequence moves to eject-chat-model.ts, taking its I/O as deps like
api-monitor/unload-resident.ts, so the scoping is under test.
Fall back to the top-level STT fields. Servers predating the per-engine split
report the resident Transformers model only there, so those installs showed no
dictation row and had no eject for it.
Bound each status read. authFetch has no timeout, so a runtime that accepts the
connection and never answers left the Promise.all pending forever, and with it
the in-flight guard that gates every later refresh.
Re-sync Images and Video after an eject. Both hold their own status and re-read
it on tab activation, not on a timer, so ejecting from the indicator left their
controls offering to generate on a freed runtime.
Add both preference keys to "Reset all local preferences", which otherwise left
the indicator hidden or collapsed after a reset.
* Studio: verify the row before the identity-less unloads
/images/unload, /video/unload and the STT unload carry no model id and release
whatever their runtime holds, so acting on a row up to one poll old could free a
model nobody clicked while reporting the clicked one as ejected. The image and
video races need a load landing in that window; dictation loads on demand and
releases when idle, so its engine can change with no user action at all.
Each now re-reads its runtime and only unloads when the resident model still
matches the row. This narrows the window to the round trip rather than closing
it, which would take a backend that accepts the model id.
Ejecting reports an outcome instead of a bare string, so a stale row says the
model is already gone and names what took its place, rather than claiming a
success it did not perform. An unreadable dictation status is now an error
rather than a false success.
* Studio: stop dictation rows printing their engine twice
The llama.cpp and whisper.cpp STT sidecars report their engine name as the
device (stt_mtmd_sidecar.device is "llama.cpp", stt_ggml_sidecar.device is
"whisper.cpp"), so a row read "Dictation - llama.cpp - llama.cpp". Only the
Transformers sidecar reports a real device.
joinDetail now drops repeated parts, so the engine is named once and a genuine
device still shows beside it.
* Studio: make the loaded models card movable and keep it off the Live monitor
The card and the Live monitor both anchor to the bottom-right, so opening the
monitor buried the card underneath it. The monitor is draggable and resizable,
so a fixed offset would only hold until it moved.
The monitor now publishes its box. The corner stack lifts above it, but only
while it is in the stack's column and low enough to be in the way, so a monitor
dragged elsewhere leaves the corner alone. This clears the update banners and
the download panel too, which had the same overlap.
The card also gets a drag handle: anchored to its corner until moved, then kept
where it was left, clamped back into view if the window shrinks. Its position
persists and joins the reset list.
* Studio: use the AI brain icon and lead the Notifications section
The loaded models card and the Train wizard's Model step both take
AiBrain01Icon, and the indicator toggle moves above the llama.cpp one so the
Notifications section leads with it.
* Studio: let the collapsed indicator be dragged too
The drag handle only existed in the expanded header, so the pill could not be
moved. The pill is now its own handle: a press becomes a drag past a few
pixels, below that it stays the click that expands the card, and reading the
flag clears it so a keyboard activation is not swallowed.
Re-clamping now also runs on a ResizeObserver, so expanding a pill dragged to
the bottom edge pulls the taller card back into view instead of growing off
screen.
* Studio: address review on the loaded models indicator
Keep the Chat picker selection when an eject leaves the model resident, so a
reload during the run no longer empties the picker while the model still runs.
Republish the Live monitor's frame from a layout effect. ResizeObserver does
not fire for a position-only change, so dragging the monitor left the overlay
stack dodging its old corner.
Cap the overlay stack's height to the space above its own bottom inset. Lifting
it over the monitor without shortening it pushed the top of a long download
list off screen.
Measure the stack column from the update banners (448px), not the download
panel (400px), so a monitor beside a banner is still dodged.
Name the precision an image or video pipeline loaded at, so a quantised build
is distinguishable from bf16 in the row.
* Studio: make the loaded models card resizable
The card is anchored bottom-right, where a native CSS resize grip has nowhere
to grow, so it resizes from a grip at the leading corner instead: the anchored
corner is held still and the box opens up and to the left. The grip shares the
title icon's slot rather than adding another control to a small header, and
double-clicking it returns the card to its default size and corner.
The size persists next to the position, is clamped to a floor and to the room
available, and is cleared by Reset all local preferences.
* Studio: open a loaded model's page from its row
Clicking a row now goes to where that model is used: chat models to Chat,
image and video pipelines to their tabs, dictation to the Voice settings tab
that drives the sidecars. The target follows the runtime holding the weights,
not the kind, so a Whisper checkpoint in the chat slot goes to Chat rather
than to dictation.
Navigation carries no search params, so it only changes page: it does not
start a new thread or reload anything. Dictation moves to the Audio page once
that lands.
* Studio: revert the resizable loaded models card
Back to the fixed-size card. Reverts
|
||
|
|
7103733d7b
|
Studio: hold the Mac capability verdict while an MLX repair can still overturn it (#8152)
* Studio: keep the Mac capability verdict provisional while MLX repair runs * Studio: bound the MLX repair hold so a scheduler that never runs cannot spin the tabs * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: gate the Mac capability verdict on a real Apple Silicon host The hold that keeps /api/health from publishing a chat-only verdict an MLX self-heal is about to overturn was covered only by unit tests, which drive it with a fake clock and a stand-in repair worker. Nothing ran it where `import mlx.core` succeeds or fails for real. tests/studio/mac_capability_verdict_smoke.py boots the real `unsloth studio` through .github/scripts/boot-studio-api-only.sh and polls /api/health from the first reply the socket gives, judging the whole sequence rather than the last answer, since the reported bug was a verdict that was wrong for a while and right afterwards. Three boots, one per scenario, because the self-heal is once per process: - real-mlx: the verdict must settle chat_only=false with /api/system reporting device_backend "mlx", and no reply on the way there may publish chat_only true. - no-mlx-settles: MLX made unimportable and UNSLOTH_DISABLE_MLX_AUTOREPAIR=1. The verdict must still settle promptly to mlx_unavailable, or a Mac with no repair coming spins Train and Video for the session. - no-mlx-repair: MLX made unimportable, self-heal on. No reply may publish the verdict while the installer runs, including well past the pre-start grace so the hold is provably the live worker, and it must settle once the install fails. MLX is made unimportable the way tests/studio/test_hardware_dispatch_matrix.py does it, through the import system, but from a sitecustomize on PYTHONPATH since there is a server to boot. It hides mlx from PathFinder rather than raising, so the find_spec in unsloth/__init__.py answers None instead of crashing. The installer in the third scenario is a stub uv first on PATH that records its argv and takes its time; utils.mlx_repair is untouched and runs its real thread, real command and real subprocess against it. The recorded argv is asserted, so a run where the self-heal never reached the installer fails rather than passing quietly. mlx-ci.yml gains the three steps plus a venv at $STUDIO_HOME/unsloth_studio, which is what `unsloth studio` re-execs into and refuses to start without. Building it over the job's own site-packages gets the real launch path without the ten minutes install.sh costs. Path filters pick up main.py, mlx_repair.py and the two boot scripts; timeout goes to 40 minutes for the in-flight boot, which waits out the torch warm before the self-heal is even scheduled. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * CI: let boot-studio-api-only.sh actually boot API-only The script exports UNSLOTH_API_ONLY=1 and its banner says --api-only, but nothing in unsloth_cli reads that variable. Whether the web UI is served is decided by the CLI flag alone; the backend only reads the variable back out in main.py to pick a CORS profile, after run.py has set it from the flag. Every caller has therefore been booting a server that serves the frontend, which is what the Playwright UI smokes need, so the flag is opt-in and no existing caller changes. The capability-verdict boots in mlx-ci.yml are the first caller without a built studio/frontend/dist (it is gitignored, and that job installs with pip rather than install.sh). Without the flag the server printed "Unsloth frontend build not found" and exited before binding, which is how this surfaced. Also in the smoke driver: give up a wait as soon as the server process is gone rather than spending the full budget, and flush the boot banner when it is written so it does not surface minutes later next to the timeout it preceded. * Studio: drop the verdict cache and bound both halves of the MLX repair hold Remove the localStorage hardware-verdict cache. It did not buy what it claimed: the store deliberately kept `fetched` false, so `capabilitiesUnknown()` is still true on a returning user's first paint and the rows spin exactly as they do without it. Its only demonstrated effect was unbreaking a `/settings` deep link, and it introduced a real regression in exchange. model-selector.tsx, pickers.tsx, model-inspector.tsx and app-sidebar.tsx read `chatOnly` with no `capabilitiesUnknown()` gate, so a cached `chatOnly=false` during a repair window offers MLX-only models that cannot load - worse than the conservative `navigator.platform` guess it replaced. env.ts and both frontend test files are back to what main has, and nothing under studio/frontend changes in this PR. Start the pre-start handoff grace on the first reading that sees the warm stopped rather than the last reading that saw it running. The warm's final stages are C-extension imports that hold the GIL for seconds at a time, so health requests queue behind them and the next one served can be the first in a minute. Measured from the last observed poll the grace could already be spent by the time anyone could ask, publishing the mlx_unavailable verdict during the very handoff the hold exists to cover. A stopped reading between two running ones is a lull, not the end, so it reopens. Bound a live repair worker with _WORKER_BUDGET_S, the subprocess timeout plus 300s. attempt_mlx_repair times the uv call but not the mlx_stack_available() imports that verify the install nor the detect_hardware() pass after it, and those import mlx.core, mlx_lm and mlx_vlm on a stack this module already assumes can park indefinitely. An alive thread was an unbounded answer, so a parked worker held the verdict provisional for the whole session. Fix _superseded_by_mlx_repair and mlx_repair_in_flight docstrings, which claimed a cap that did not exist. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> |
||
|
|
95d9f23a64
|
Normalize the /api/health wait and extract it into one script (#8060) | ||
|
|
7e47cc03c1
|
Extract the API-only Studio boot into one script (#8029)
Twenty-one steps across eight workflows ran the same five-line body, varying only in port, log filename and the name of the environment variable the pid lands in. .github/scripts/boot-studio-api-only.sh takes those three as flags. Three parts of that body are easy to get subtly wrong, which is the argument for one definition rather than twenty-one: rm -rf, not `reset-password`. The boot only re-seeds a fresh .bootstrap_password when the auth directory is absent, so a caller that resets keeps the old password and the test authenticates against stale state. The pid has to reach $GITHUB_ENV, because the step that stops the server is a different step with a different shell. Both streams go to the log. The server writes startup diagnostics to stderr, so a redirect without 2>&1 leaves an empty log on exactly the failure someone needs it for. Not folded into serve-unsloth-run.sh, despite the plan saying so. That script boots `unsloth run --disable-tools` and its body is banner-key parsing and /v1/models resolution; these boot `unsloth studio --api-only` with auth wiped and no banner. The two share the word boot and nothing else, and each header now says which is which. Deliberately does not wait for health: the callers' waits differ, some polling /api/health and stopping while others go on to rotate the password and load a model in the same step. Left alone: three boots inside Playwright retry loops, which re-boot mid-loop and reassign the pid in the running shell, and three boot-briefly-and-kill steps that keep the pid in a local variable rather than $GITHUB_ENV. Each caller's paths filter gains the script, so an edit to it re-runs the workflows that call it -- the trap #8015 hit when the install body moved out from under those filters. |
||
|
|
607b928adf
|
CI: run the three mac inference smokes as phases of one job (#7978)
* CI: run the three mac inference smokes as phases of one job
The three jobs in this workflow differed only in model, port and test body.
Their checkout, setup-node, setup-python, install.sh --local --no-torch and
assert-llama-loads.sh preamble was byte-identical, so every triggering PR ran
the same install on three macos-14 runners at once. Concurrent macOS jobs are
capped at 5 per account and that pool is shared with unslothai/unsloth-zoo, so
this one workflow took 3 of the 5 slots to derive the same install three times.
They are now three sequential phases sharing a single install. Each phase keeps
its own model cache, port, server log and uploaded artifact, so a failure still
names one phase and the earlier phases' logs survive it.
Collisions the merge had to resolve, none of which were visible from the job
list alone:
- cache-gguf and download-gguf were defined in both the tool-calling and
json-images jobs. Duplicate step ids inside one job are a hard error, so
they are suffixed -tools and -vision.
- both jobs cached to the same gguf-cache directory with different models.
Merged, the vision phase could have restored tool-phase bytes. Split into
gguf-cache-tools and gguf-cache-vision.
- all three boots wrote logs/studio.log, so merged, each phase would have
truncated the previous phase's diagnostics. Split per phase.
Phase-specific values are set only through , never at job level.
Whether a later write beats a job-level env: key of the same name
is not something the Actions docs pin down, and a phase quietly talking to the
previous phase's port is a bad way to discover the answer. Everything inside
${{ }} is a literal for the same reason: a cache key that silently resolved to
the wrong model would still pass, just against the wrong bytes. The literals
resolve to the same strings as before, so existing caches still hit.
Verified: one job, 37 steps, no duplicate ids or artifact names, no dangling
steps.* references, exactly one install, three distinct ports and logs, and the
extracted test-command set is identical to before once the cache-directory
rename is normalised.
macOS jobs in this repo: 29 -> 27 on top of #7975 (31 -> 29 on main alone).
* CI: bump the renamed GGUF cache keys so the merged phases can cache at all
Caught by an A/B on staging CI, not by inspection.
Splitting gguf-cache into gguf-cache-tools and gguf-cache-vision kept the cache
keys byte-identical, which looked safe. It is not. actions/cache identifies an
entry by key AND a version hash derived from the path input, while key
uniqueness is global per ref. So the pre-existing gguf-cache entries still hold
the old keys: a restore into the new directory misses on version, and the
matching save is then refused with "Unable to reserve cache with key ...,
another job may be creating this cache".
The result is not a one-time repopulation. It is permanent: two consecutive
staging runs of the merged workflow both logged "Cache not found for input
keys" and both failed to save, so both re-downloaded the tool model (~890MB)
and the vision model (~2.4GB + 986MB). That is roughly 3.3GB per run, forever,
on a macOS runner from a pool capped at 5 concurrent jobs.
Bumping the key suffixes (tools v1 -> v2, vision v2 -> v3) gives the new paths a
free key so the entries actually populate.
The rule this encodes: changing an actions/cache path requires changing the key
too, even though the key alone looks like it fully identifies the entry.
* CI: keep per-phase timeouts and phase independence in the merged mac smoke job
Merging the three mac inference jobs into one job lost two properties the
separate jobs had.
A step with no explicit `if:` gets an implicit `success()`, and that check is
job-wide, not per-step. So one failed step in phase 1 skipped every remaining
step of phases 2 and 3, and a tool-calling or vision regression went unreported
until phase 1 was fixed and the job re-run. Phases 2 and 3 now run under
`!cancelled() && steps.assert-llama.outcome == 'success'`: a failure inside a
phase no longer skips the phases after it, while a failure in the SHARED
preamble (checkout, install, llama.cpp load check) still skips them, since
assert-llama is itself skipped in that case. The job still ends red, because the
failed step already set the job status.
The three jobs were also capped at 25 / 25 / 30 minutes each; the merged job had
only the aggregate 90. hf-download-with-retry.sh retries forever and names the
enclosing timeout as its bound, so one stalled download could hold a macOS slot
for the full 90 minutes and leave nothing for the phases behind it. Every step
that can block now carries its own timeout-minutes, with 90 kept as the outer
guard.
Also drops the two leftover job divider comments that still announced jobs 2 and
3 in the middle of the step list.
* CI: keep the shared preamble out of phase 1's failure chain
The previous round gated phases 2 and 3 on steps.assert-llama.outcome, so a
failure inside one phase no longer suppresses the others. It left the two shared
preamble steps on the implicit success() check, which reopens the same hole one
level up.
Install Unsloth and Assert llama.cpp both had no if:, so GitHub applied
success(), which is true only when every previous step succeeded. Phase 1's
Prime HF_HOME sits above them and carries a 15-minute timeout, and a step
timeout marks the step failed rather than cancelled. So a stalled or failing
phase-1 model download skips Install, skips Assert, leaves
steps.assert-llama.outcome == 'skipped', and silently takes phases 2 and 3 with
it. Before the consolidation those were independent jobs and still ran.
Install now runs under !cancelled(), and Assert gates on the install step
explicitly instead of on the job-wide implicit check. A phase-1 GGUF problem is
a phase-1 resource problem: the install probe that install.sh itself needs is
fetched live by hf_hub_download when the cache is cold, so the bootstrap can
still succeed and tool-calling and vision still get exercised. A genuinely
broken bootstrap still fails Install, which still skips Assert and correctly
holds both later phases out.
Ordering is unchanged, so no cache key or path moves.
Behaviour, verified against the step table: all green -> phases run; prime-hf
fails or times out -> phases run; checkout, setup-python or install fails ->
phases skipped; assert fails -> phases skipped; run cancelled -> all skipped.
* Bound the phase-1 cache actions in the mac inference smoke job
Phases 2 and 3 got timeout-minutes on their cache restore/save pairs in
the previous follow-up, but phase 1's pair was left unbounded. Before
consolidation that phase was a separate job with its own 25-minute
timeout, so a stalled cache action was bounded by it; after the merge
the only bound is the job's 90 minutes, and a stall there would hold a
macOS slot and starve phases 2 and 3.
15 minutes matches the sibling Prime HF_HOME step and phase 2's pair.
|
||
|
|
1301e16004
|
Keep the file-edit turn 2 prompt to one instruction, and stop waiving its cap (#7846)
* Keep the file-edit turn 2 prompt to one instruction, and stop waiving its cap * Tighten the turn 2 comments |
||
|
|
cf4acbcce5
|
Make the Colab oracle tripwire able to fire, and stop blaming start.py for a hung agent CLI (#7838)
* Make the Colab oracle tripwire able to fire, and stop blaming start.py for a hung agent CLI * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Keep a connection-mode cap fatal, and do not read 137 as a timeout * Note that the opencode non-exit is intermittent * Close three gaps the waiver opened: turn-2 side effect, attribution-ab, and the guard tests * Bound a TERM-resistant CLI again, and judge a capped turn 2 on an artifact * Gate both timeout statuses on the clock, keep the cron lint reachable, and make refresh --all atomic * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> |
||
|
|
3c6343b455
|
Sign the NSIS plugin DLLs, and fail the release on any unsigned file (#7819)
* Sign the NSIS plugin DLLs, and fail the release on any unsigned file Both 0.1.51-beta and 0.1.512-beta ship four unsigned DLLs: NSISdl.dll, System.dll, StartMenu.dll and nsDialogs.dll. Only nsis_tauri_utils.dll carries the Unsloth AI Inc. signature. tauri-bundler is doing the work already. When a signing identity is configured it copies Plugins/x86-unicode aside, signs all five files listed in NSIS_PLUGIN_FILES, and exports the copy as the NSISPLUGINS environment variable. Nothing consumes it: no NSIS template references $%NSISPLUGINS%, and the only plugin directory added is ADDITIONALPLUGINSPATH, which points at the copy's additional subdirectory and holds exactly one file. So one DLL resolves from the signed copy and the other four fall back to the default unsigned one. Add the directory in our template, guarded so unsigned local builds still compile, and add a check that unpacks each bundle and fails listing every unsigned executable rather than stopping at the first. * Fail the signing gate when a bundle cannot be unpacked 7-Zip leaves a partial tree behind on error and can fall back to its PE handler, which yields sections rather than the payload. Both cases passed the gate having verified nothing. Move !addplugindir above the includes so the signed copy is registered before any plugin is packed. * Tighten comments on the bundle signing gate * Sign the bundled install script and gate on it install.ps1 ships as a bundle resource and is the first thing the app runs after install, but the bundler never signs it and the gate did not look at scripts. Sign it before the build packs it in, and add .ps1/.psm1 to the checked set. Verified on a Windows runner that signtool attaches a valid signature to a .ps1; trusted-signing-cli lists ps1 as supported and does not filter by extension. * Tighten comments on the bundle signing gate * Reduce the Windows false positive surface Three changes, none of which alter behaviour: install.rs no longer passes -WindowStyle Hidden -ExecutionPolicy Bypass to powershell.exe. CREATE_NO_WINDOW already suppresses the console and the NSIS-extracted script carries no mark-of-the-web, so RemoteSigned loads it. That flag pair is the command line Microsoft ships as a detection test. install.ps1 installs uv from a pinned, SHA-256 verified release archive instead of evaluating a downloaded script in-process. installer.nsi fills in CompanyName and InternalName, which upstream's template leaves empty. * Match astral's installer on unmanaged installs, mirrors, and the PATH probe UV_UNMANAGED_INSTALL forces no-modify-path in astral's installer, so it must here too, and honour UV_INSTALLER_GHE_BASE_URL / UV_INSTALLER_GITHUB_BASE_URL so a mirrored environment still works. Every mirror serves the same asset, so the pinned hash is unchanged; UV_DOWNLOAD_URL stays unhonoured because it points at an arbitrary version the pin would then reject. Record where uv actually landed. Refresh-SessionPath rebuilds PATH machine first and drops the in-process prepend, and the recovery probe never checked the XDG_DATA_HOME or unmanaged destinations, so a good install could still report failure. Required by the change above, which leaves that prepend as the only thing putting uv on PATH. * Tighten comments on the false positive reduction changes --------- Co-authored-by: danielhanchen <danielhanchen@users.noreply.github.com> |
||
|
|
b35bdcbea3
|
CI: prove an interrupted install can never masquerade as a healthy one (#7552)
* CI: prove an interrupted install can never masquerade as a healthy one Nothing in CI had ever interrupted an install, which is how the reported failure shipped: quit the desktop app mid-install, the app SIGTERMs the installer process group, and if that lands during 'studio deps' the venv loses structlog. Preflight then probes 'unsloth -h' and 'studio desktop-capabilities', both of which succeed because the CLI's own deps are core, so the app reported ManagedReady with can_auto_repair=false while the backend died on import. A permanent dead end. This kills the installer at each interesting phase and asserts the result is either genuinely healthy or explicitly repairable, never silently ready. 13 legs across macos-14, ubuntu-latest and windows: each of the dependency-pass steps plus the coarse phases (venv, torch, unsloth, setup). The kill targets the process GROUP, matching install.rs. Killing only the leader leaves uv and python children to finish the dependency pass, and the test would quietly prove nothing. Windows has no process groups, so that leg walks the CIM parent links instead, which is the same reason the app carries windows_job.rs. One shared probe for all platforms. The Windows check used to be bespoke inline PowerShell that only ran -h and desktop-capabilities, so it could not observe studio_install_ok, verify-install or desktop-runtime-check: it would have reported FALSE_READY for the very PRs that add them, no matter how well they worked. The probe boots the backend as ground truth and owns the whole process tree, since terminating only the parent leaves children holding the port. install.sh runs with --local, which is load-bearing rather than a convenience: without it the installer resolves unsloth from PyPI and the venv gets the PUBLISHED CLI, so no branch-side change is present and every deeper probe reports 'absent' regardless of what the branch does. Verified: against a tree without the detection, windows kill@studio-deps reports FALSE_READY, reproducing the user report exactly. With #7492 merged the same leg reports REPAIRABLE, and all 13 legs pass. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Make the POSIX legs actually run the installer, and fail if they do not install.sh --tauri rejects a custom UNSLOTH_STUDIO_HOME outright (the desktop app still uses the legacy ~/.unsloth/studio root), and this workflow set one at workflow level for every job. So all 11 macOS and Linux legs exited about a second in with ERROR: UNSLOTH_STUDIO_HOME is not supported with --tauri. produced no CLI, took the probe's NO_CLI 'safe' branch and reported success. They were vacuously green. Only the two Windows legs were real, because install.ps1 has no equivalent guard. The override now applies to the Windows job only, and the POSIX legs read the legacy root, which is where --tauri installs. The runner is ephemeral so the real home is as disposable as the override. Also adds the check that makes this class of mistake loud: a leg asserts its kill actually landed on the marker it was aimed at, using the interrupt_reason the driver already records. A leg that never reached its kill point proves nothing, and NO_CLI made that indistinguishable from a pass. * Make the interrupted-install legs able to fail The probe treated a present .desktop-install-in-progress marker as proof of a repairable state, but the drivers seed it unconditionally and never clear it, so REPAIRABLE was unconditional and FALSE_READY unreachable. The Windows leg had no kill-landed guard, blanket continue-on-error, and no repair re-run; -SkipTorch was silently dropped, since install.ps1 parses only --no-torch. Judge the re-run by whether the backend boots, on both platforms. The log grep matched the frontend build printing "up to date" and failed a leg whose venv was fine. * Drop the interrupt cell that could never be interrupted install.sh --local sets skip_base, so install_python_stack returns before any "base packages" label is printed. The kill had nothing to land on and the installer ran to completion, reaching [TAURI:DONE] in 62s. * Fail the leg when the installer finished instead of being killed The driver set reason=marker-hit before the post-marker sleep and never rechecked, so a step whose work was already cached could run to completion inside that beat and still be recorded as an interruption. The landing assertion tests reason != marker-hit, so a fully completed install passed green having interrupted nothing. Reproduced with a stub that exits during the delay: reported marker-hit / killed=true / exit=0 next to "install finished fully". Set the reason after the sleep, on both drivers. Also trigger on _studio_deps.py and install_manifest.py, where the two decisions the probe asserts on are actually implemented. * Kill the group, and stop the probe blocking on a full pipe The escalation was gated on the leader still being alive, so a leader that exits promptly on SIGTERM while a uv or python descendant ignores it skipped the SIGKILL entirely, and wait reaped only the leader. Proven with a descendant that traps TERM: pre-fix its heartbeat keeps ticking while the probe would be running, post-fix it stops. Signal the group unconditionally and drain it after the reap, since an unreaped leader is still a member of its own group. The probe started the backend on stdout=PIPE and read nothing until after the poll loop, so a backend logging more than the pipe buffer during import blocked before binding. Measured 65536 bytes here; a child emitting 200 KB never reaches its bind line, which would make backend_ok false for a healthy install. Write straight to the artefact file. Also trigger on studio/backend/requirements/**, where structlog is declared. * Make the NO_CLI legs assert repair, and fix the Windows straggler sweep Two of the interrupted-install legs were passing without testing anything. The re-run assertion skipped verdict=NO_CLI, but a kill at "venv" or "torch" lands before install.sh ever prints "Installing Unsloth" (:2125, :3667, :3961), so those legs can only ever produce NO_CLI. Three non-gating-exempt cells (macos-14 kill@venv, macos-14 kill@torch, ubuntu-latest kill@torch) therefore asserted nothing beyond a marker appearing in a log. NO_CLI is now included: a re-run must produce a booting backend regardless of how little the first run managed to install. Each re-run step grows an existence check first, because the probe exits without writing verdict.json when the binary is absent and the json.load would crash rather than report. The Windows straggler sweep matched nothing at all. UNSLOTH_STUDIO_HOME arrives as D:\a\r\r/.studio-home, since the workflow joins ${{ github.workspace }} with a forward slash, while Process.Path is all backslashes, so the literal -like missed even the venv's own python.exe. uv is never under the studio home in any case: install.ps1 takes it from winget or astral.sh. Normalise the separators, match uv by name (the runner is ephemeral and runs no other uv), and skip the home comparison entirely when the variable is empty, which would otherwise turn the pattern into "**" and kill every python on the runner. * Run the Windows legs as the desktop does, and judge repair by what preflight reads The Windows matrix set a workspace-scoped UNSLOTH_STUDIO_HOME, which forces install.ps1 down the shell-install path: install.ps1:189-215 rejects a custom root under --tauri, so those legs ran with UNSLOTH_TAURI_MODE=0, the frontend build on and no bundled-file overlay, while the desktop always spawns the installer as --tauri with the variable scrubbed (install.rs:202 and :356). The torch leg could not even reach its marker: "Installing PyTorch" is printed only by Write-TauriLog (install.ps1:2440), so it was killed at the deadline. Both legs now run --tauri --local at the default root, and the probe and re-run resolve the CLI under %USERPROFILE%\.unsloth\studio. The probe counted `studio verify-install` and `studio desktop-runtime-check` failures as proof the app can repair, but preflight/managed.rs runs only `-h` and `studio desktop-capabilities --json` (:357) and reads studio_install_ok from that payload (:445); neither deeper command is invoked anywhere under studio/src-tauri. A leg where capabilities regressed to ready while only those standalone commands saw the damage would have passed green with the app stuck on ManagedReady, which is the exact false negative this workflow exists to catch. They are still run and recorded in verdict.json, just no longer repair evidence. An interrupted install can leave the console script in place while its venv interpreter is gone. The probes go through run(), which catches OSError, but the backend spawn did not, so the probe aborted before writing verdict.json and both workflows died on the json.load instead of reporting. That state is now recorded as backend_spawn_error and lands on REPAIRABLE, which is what `-h` failing already implies. On win32 the CLI re-spawns the server as a child and waits on it (unsloth_cli/commands/studio.py:1543), and CREATE_NEW_PROCESS_GROUP does not make terminate() reach descendants, so the reap left a server holding the venv open while the repair step reinstalled into files Windows had locked. Use taskkill /F /T for the tree. The straggler sweep now falls back to the default studio root, since under --tauri there is no UNSLOTH_STUDIO_HOME to match on. * Judge the install the way preflight does, and reap the whole probe group Read desktop-capabilities the way the desktop reads it. preflight/managed.rs pipes stdout and sends stderr to /dev/null (managed.rs:358), then hands the whole stdout buffer to serde_json (managed.rs:414). The probe concatenated both streams and scanned to the first brace, so a single diagnostic line on stderr made json.loads raise on the trailing text, studio_install_ok stayed "absent", and a broken backend was reported FALSE_READY over an install the real app parses, sees as incomplete, and offers to repair. That fails a valid recovery change for a reason that exists only in the probe. stdout and stderr are now captured separately and stdout is parsed strictly; a payload that does not parse counts as repair evidence, matching the Stale the desktop reports when the capability probe returns nothing (managed.rs:521). A booting backend alone is not a finished install. The manifest is written last (install_python_stack.py:3255), so a kill after "studio deps" but before it, the data-designer leg, leaves a venv whose backend boots while desktop-capabilities still reports studio_install_ok=false and preflight reports Stale (managed.rs:445). Calling that HEALTHY skipped the re-run step, so the leg asserted nothing beyond a marker appearing and never exercised the version fast path that is supposed to clear an incomplete install, which is the half of the bug that strands the user. HEALTHY now requires both. Escalate to the process group after reaping the probe's backend. reap() returned as soon as proc.wait() succeeded, and the leader exits promptly on SIGTERM while a uvicorn worker does not, so the SIGKILL iteration was skipped and that worker kept the port and the venv open while the repair step reinstalled underneath it. It also read os.getpgid(proc.pid) after the reap, which raises. The pgid is now captured up front and SIGKILL always goes to the group, the same escalation interrupt-install.sh:94 makes. A heartbeat experiment left the group alive with the old sequence and empty with the new one. Trigger the workflow on pyproject.toml. Every leg installs the checkout with --local, so that file decides the unsloth console script and the core dependencies the probe leans on: -h and desktop-capabilities only survive a torn install because typer/click/rich are declared there. No other install workflow interrupts the installer, so such a change would otherwise merge without a single leg running. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Judge an absent capability field and a dead -h the way preflight does The probe left studio_install_ok=absent undecided and judged those installs on whether the backend booted. preflight/managed.rs:445 tests studio_install_ok != Some(true), so an absent field is Stale exactly like a false one; a CLI too old to carry it is already rejected one check earlier on desktop_manageability_version. The gap mattered in both directions: a payload that stopped carrying the field reported HEALTHY on every booting leg and skipped the re-run assertion this workflow exists to make, and a torn venv with a working -h was failed as FALSE_READY even though the app would have offered repair. unsloth_cli/commands/studio.py is in this workflow's path filter precisely to catch that class of change, so it must not be the thing that silences it. The verdict also consulted cli_h_ok only in the repairable arm, so a CLI that cannot print help was called HEALTHY whenever the backend happened to boot. probe_managed_bin runs -h first and returns Stale cli_unusable before it ever reaches the capability probe (managed.rs:465-478), so that install goes to repair in the real app and the leg must assert it here. * Judge the probes on the desktop's deadline, and interrupt the host it uses Preflight gives each managed probe ten seconds and nothing more: managed.rs:337 wraps `unsloth -h` and managed.rs:390 wraps `studio desktop-capabilities --json` in a tokio timeout, kills the child on expiry, and returns Stale as "cli_unusable" or "desktop_capability_probe_failed". The probe allowed three minutes, so a venv torn badly enough that its CLI only answers after half a minute of retries was recorded HEALTHY here while the real app shows it as repairable. That skips the re-run assertion the leg exists to make, which is the same false-HEALTHY hole the studio_install_ok and -h gating already closed. Both calls now use the desktop's ten seconds, and the elapsed time is recorded so a leg that flips for timing reasons says so in the artefact. On Windows the installer child now runs where the desktop runs it. install.rs 325-339 spawns the bundled install.ps1 as powershell.exe with -NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File, so Windows PowerShell 5.1 is the only host a real desktop install ever uses. The interrupted run and the repair re-run both used pwsh 7, and every other Windows job in .github runs install.ps1 under pwsh too, so the installer's behaviour on 5.1 was covered by nothing: .NET Framework instead of .NET, OEM console encoding instead of UTF-8, and different native-command and OSArchitecture reporting are all real sources of divergence. A workflow whose point is to reproduce what the app does cannot run a different interpreter than the app does. The driver itself stays under pwsh; only the installer child and the repair invocation change. * Tighten the interrupted-install comments * Tighten the probe docstrings * Fail the leg when the installer completed inside the kill window * Land the kill in the marked step, and reject non-boolean capabilities Two holes found from the staging run's own logs. The venv leg never interrupted the venv step. Creating the venv takes ~0.1s, so by the time the 1s poll noticed its line the installer was already in "Installing PyTorch", and the flat 3s beat sent the signal there: staging run 30419729244 shows both step lines in the tail and a kill 4s in. That made the leg a duplicate of the torch leg while its label claimed otherwise. Both drivers now poll in half-second slices, cut the beat short the moment a later [TAURI:STEP] line appears, and print the step the signal actually landed in, warning when it is not the marked one. Sub-step markers such as "studio deps" print no step line, so they keep the whole beat and never warn. studio_install_ok is Option<bool> (managed.rs:43), so serde rejects a non-boolean and the whole payload fails to deserialize, which the desktop reports as Stale. bool() read a JSON string "false" as True, so the probe called a torn install ready. Only a literal JSON true counts now. * Tighten the interrupt driver comments * Fail the leg when the signal landed after the marked step The cut-short added last round only helps when the marked step is still the last [TAURI:STEP] line at the moment the poll notices it. Creating the venv takes ~0.1s (staging run 30419729244: 03:31:07.371 -> 07.478), less than the 0.5s poll, so the next step's line is usually already in the log when the marker matches, the step count never changes during the beat, and the full 3s elapses inside "Installing PyTorch". Reproduced with a stub installer against the driver at head: kill at 4.11s, step at kill "Installing PyTorch". The leg then duplicates the torch leg while its matrix label claims the venv step, and passed green on nothing but a :⚠️:. Both drivers now skip the beat entirely when the marked step is already over, so the kill goes out at once instead of deeper into the next step, and both record interrupt_step_mismatch in interrupt.env. The landing assertion fails on it: a warning that cannot fail the leg proves nothing. Sub-step markers ("studio deps", "pip bootstrap") print no step line of their own and stay exempt, as before. The venv leg becomes experimental. Its step is shorter than any log poll can resolve, so it must not block the PR on a race it cannot win, and it still probes the earliest torn state whenever it does land. * Tighten the interrupted-install workflow and driver comments * Land the kill in the phase each leg is named for Splitting the log on \r shows that 5 of the 12 legs of staging run 30419729244 interrupted a later phase than their label claims, and the run was fully green. The venv leg's install.log is byte-identical to the torch leg's. So is pip-bootstrap's to unsloth-extras'. Worse, both "studio deps" legs, the cells that reproduce the reported bug, were killed at "7/10 data designer deps" and "12/14 local plugin": their own probe artefacts report backend_ok=true, so structlog was installed and the flagship cell was passing on the manifest gate alone. Two causes. The dependency pass rewrites ONE physical line with \r (install_python_stack.py:2499), so its sub-steps are CR-separated segments and a line-based check could not see one end; the drivers exempted them and warned about nothing. And the flat 3s beat between the marker and the signal is longer than several phases, while every phase label prints BEFORE its work starts, so the beat pushed the signal past the phase instead of into it. Both drivers now split on \r, track the running phase at both levels, and judge a sub-step marker against the running sub-step and a step marker against the running step, so a step is not "over" because the sub-steps beneath it advanced. The beat defaults to 0 and is set per leg, 3s only for torch, unsloth and setup, which run for minutes. The mismatch is recorded in interrupt.env and the landing assertion fails on it, in both languages. Both detectors were replayed against the 12 real logs from 30419729244 and agree with the artefacts on every leg. venv and pip-bootstrap become experimental: their phases are shorter than any log poll can resolve. * Fail the Windows leg when the installer never exited The driver writes installer_exit=running when the installer outlived Stop-Tree and WaitForExit, and 'running' is not '0', so the landing assertion accepted it. A live installer writing into the venv while the probe reads it is not an interrupted install. Only a real integer exit code counts now, checked against 0, 143, 137, -1, running and the empty string. * Drop the legs that cannot land, and prove the kill was delivered Two cells never interrupted the phase they were named for. "Creating virtual environment" runs 0.107s (staging 30419729244, 03:31:07.371 -> 07.478) and "1/10 pip bootstrap" is over just as fast, both shorter than any poll that watches the log, so in 30423181897 and 30424366953 the signal landed in "Installing PyTorch" and "2/10 unsloth extras" every time. Each was another leg wearing a false label, so they are gone rather than allowed to fail, and continue-on-error goes with them: a leg permitted to fail asserts nothing. The only coverage lost is a venv caught half-written, which interruption cannot reach at this resolution; the torch leg's signal lands ~3s into a multi-minute download, so it already leaves a complete venv with nothing installed into it. The landing check also accepted an installer that failed on its own. A dependency error between the driver's last liveness check and the signal exits non-zero, which the exit != 0 guard let through as a kill. POSIX now requires 143 or 137, the only statuses a signal produces here and what all ten POSIX legs of 30424366953 reported. Recording whether kill(2) returned 0 would not separate them, since the unreaped leader keeps its group alive. Windows has no such status, so the driver records whether Stop-Process actually terminated the installer: it throws on a process already gone, so the flag is false exactly when there was nothing left to interrupt. * Signal at the marker, with no beat to overshoot the phase Staging run 30426111484 failed the macOS torch leg on the landing check: the 3s beat carried the signal from "Installing PyTorch" into "Installing Unsloth", because the PyTorch step, which this workflow called minutes long, finished in under three seconds. The beat only ever existed to land mid-work, and it cannot do that safely: every label prints before its work starts, so detection is already inside the phase, and any wait is a bet on how long that phase runs. It lost in 30419729244 and again here. So the beat is gone rather than retuned, and with it the matrix knob and the driver parameter on both platforms. The landing check stays and can still fail, since a phase shorter than one poll is seen only after it ends. The Windows driver also polled every 500ms while its own comment claimed a fifth of a second. That is 2.5 slices of overshoot the POSIX side does not carry, and it is now 200ms like the POSIX loop. * Kill the installer before its children, not after The depth-first walk killed the child install.ps1 was waiting on and only then the root, which races the leader's own reaction to that death. It is not a theoretical race: in staging run 30424366953 install.ps1 had already printed "unsloth studio setup failed (exit code -1)" by the time Stop-Process reached it. A leader that wins the race makes Stop-Process throw, and the new root-kill assertion would then fail a leg whose interruption the driver really did deliver. The tree is now snapshotted first, since a dead parent leaves nothing to walk, then the root goes down ahead of its descendants. A dead leader cannot react to a child and cannot respawn one either, which is what the depth-first order was for. * CI: give the probe the desktop's startup grace and fail a nonzero repair The probe allowed the backend 120s to answer /api/health while the desktop waits 5 minutes (BACKEND_STARTUP_GRACE_PERIOD, commands.rs:9), so a slow but healthy install could be reported FALSE_READY. A broken backend exits at once and the poll breaks on it, so the longer deadline only bounds a live backend. The re-run step also accepted a HEALTHY probe over an installer that exited nonzero. setup.sh does fallible sidecar and GPU setup after the manifest is written, and the desktop returns the repair error without starting the backend (commands.rs:615-630). The Windows leg ignored powershell.exe's status entirely. * Tighten the interrupted-install comments Comments only: shorter wording for the same rationale, no code touched. * CI: raise the interrupted-install job timeout above its own deadlines A leg configures up to 25 minutes to the marker plus two probe passes of up to 17 minutes each around a repair install, so the 60 minute limit could cancel a slow runner mid-assertion. Legs land in 6 to 10 minutes in practice. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen <unslothai@gmail.com> |
||
|
|
b6781a8bfe
|
CI: prove the installer works on a machine with no developer toolchain (#7551)
* CI: prove the installer works on a machine with no developer toolchain
No job has ever run the installer on a machine without one.
studio-mac-install-matrix.yml is the only macOS installer job and it runs
'bash install.sh --local --no-torch' on runners that already have the Xcode CLT
selected and setup-python preinstalled, so the CLT gate never fires there, and
--local is precisely the mode that legitimately needs git. Repo-wide there was
zero coverage of xcode-select or CommandLineTools outside install.sh itself.
clean-machine-install-ci.yml runs the installer on a genuinely stripped machine.
macOS legs move /var/db/xcode_select_link, /Library/Developer/CommandLineTools,
/Applications/Xcode*.app and Homebrew aside, so xcode-select -p, git, cc and
clang really do fail, and restore unconditionally afterwards. Removing the
select-link alone is not enough: xcode-select falls through to a full Xcode.app
and re-arms /usr/bin/git. Linux legs use containers, which are genuinely clean.
Windows legs cover winget visible and masked, plus windows-11-arm. A WSL leg
covers the 126 lines of WSL-specific install.sh logic that had no runtime test.
Each macOS leg runs four deliveries: pipe (the advertised command, and the shape
that turns an early exit into curl (56)), file (separates installer logic from
pipe delivery), no-torch, and tauri (stdin closed, no tty, as the desktop app
invokes it). One leg records every toolchain invocation and asserts the trace,
which is the real deliverable: proof the installer never reached for a compiler
rather than proof it happened to succeed.
The asserts test that tools do NOT WORK rather than that they are absent from
PATH. On a real virgin Mac /usr/bin/git and /usr/bin/cc exist as CLT stubs, so
'command -v git' succeeds and only running it tells the truth.
desktop-app-clean-machine-ci.yml installs and launches the SHIPPED desktop app
release on a stripped machine, covering Gatekeeper and quarantine on macOS, NSIS
silent install on Windows, and Xvfb with WebKit2GTK on Linux.
Known limit, stated plainly: hosted macOS runners are developer machines. Masking
reproduces this bug and proves the installer does not invoke a toolchain, but it
cannot prove no hidden dependency exists on a truly virgin Mac. An ephemeral-VM
lane is the follow-up.
* Point the llama assert at the right root, and name the Intel limitation
The tauri leg installs to the legacy root because --tauri refuses a custom
UNSLOTH_STUDIO_HOME. Its install succeeds end to end, but llama.cpp lives at
<root>/llama.cpp while the venv is at <root>/studio, so the assert was pointed one
level too deep.
On macos-15-intel /usr/bin/git keeps working once the CLT are gone, so it is not
CLT-provided there and no masking can remove it, while cc and clang do become
stubs. Calling that 'masking failed' was wrong. That leg allowlists git
explicitly and says why, so the assert stays strict everywhere else.
* Make the clean-machine legs able to fail
The toolchain strip never ran on the automatic triggers: inputs exists only for
workflow_dispatch, and GitHub coerces '' and false alike to 0, so
`inputs.strip_toolchain != false` was false. Confirmed on a pull_request run
where the strip step reports skipped. Gate on the event instead.
Also: scrub the Machine and User registry PATH, since install.ps1 rebuilds
$env:Path from them mid-install and the toolchain came back; stop dropping
WindowsApps unconditionally, which removed winget on the winget=visible leg too;
fail rather than annotate when a bundle ships no installer or no CLI; run the
bundled installer, which a headless launch never reaches; resolve the newest
desktop-v* release instead of a pinned immutable tag; and give the two macOS
matrix rows distinct artifact names.
* Make the Windows and Linux clean-machine legs honest
The Windows scrub only touched PATH, so the legs were green while not clean: run
30365014702 logged "python ABSENT" and then "Python 3.13 already installed"
with uv resolving C:\hostedtoolcache\windows\Python\3.13.14\arm64\python.exe.
py.exe lives in C:\Windows and uv discovers interpreters itself, so take the
toolcache off disk and fail when tooling survives, instead of only printing it.
The Linux desktop legs never stripped anything, and the tauri.log step was all
|| true so it could not fail. Run the bundled installer the way install.rs does,
with --tauri alone, and assert torch: passing --no-torch skipped the slowest
half of first launch and let the venv check pass over it.
Pin the WSL rootfs to a dated build; current/ is a rolling alias and the digest
next to it is fixed.
* Give the Linux and WSL legs an assertion that can fail
The Linux rows' only post-install gate was nobuild, a log grep, so an installer
exiting 0 having produced nothing kept a required leg green. The WSL job and the
Windows job both already check the install runs; the Linux job now does too.
The WSL detection half only printed its Select-String, and the alternation also
matches "platform linux", so a regression that skipped every WSL-specific
branch would still pass as a plain-Linux install. Assert the exact marker,
stripping ANSI first since step writes the label in reverse video. Probed against
three fixtures: real wsl log passes, platform linux fails, missing log fails.
* Tighten the clean-machine comments
Compress the comment blocks across the clean-machine workflows and
scripts. The explanations of why each check is written the way it is
stay; the padding, restatement and duplication go.
No code or workflow logic changes.
* Point the nightly at the repo that publishes, and let its checks fail
REL_REPO defaulted to unsloth-test/unsloth-test, which holds one release frozen
at 2026-07-27, while release-desktop.yml publishes into github.repository. The
schedule was re-testing the same fixture forever and could never see a broken
production bundle.
The windows job carried a blanket continue-on-error, so its NSIS assertions
could not gate. lipo -archs prints and exits 0 for a thin binary and `|| true`
swallowed even that, so the architecture was never checked; fall back to file,
which survives the CLT mask. And require the preflight disposition line rather
than the mere existence of tauri.log, which setup_logging creates at process
start regardless.
* Stop four clean-machine checks from passing over a real failure
Re-run `absent` after the install on the masked macOS legs. It only ran
before, so an installer that quietly selected the Xcode CLT or installed a
compiler left the leg green while every later source build could succeed,
which is the one thing clean-machine-assert.sh says `absent` guards the whole
run against.
Fail the Windows simulation when py.exe can still start an interpreter. The
launcher binary itself may stay, but Find-CompatiblePython probes `py` first
(install.ps1:1130-1153), so an interpreter registered outside the two renamed
toolcache directories gets reused and Python bootstrap is never exercised.
Exempting `py` without ever running it left that unchecked.
Propagate the WSL installer exit code. It was printed and discarded, and the
CLI check does not compensate: install.sh links the `unsloth` shim (4174-4182)
before it reports a failing studio/setup.sh (4219-4230), so a late setup
failure leaves a shim whose --version succeeds.
Run the bundled installer in the Linux desktop jobs. The launch step only
proves the process stayed alive, and on a fresh home preflight reports
not_installed and the app waits on the install screen, so both required rows
passed after 90 seconds without ever touching the shipped install.sh. Locate
the resource in the deb payload or the extracted AppImage, run it the way
install.rs does, and require a managed venv that can import torch.
* Prove the trace wrapper records before trusting an empty trace
The `notools` check reads an absence: it passes when the trace file contains no
compiler, git or brew invocation. A shim directory that never reached PATH
produces exactly the same empty file as an installer that touched nothing, so
the single leg carrying that assertion would stay green no matter what the
installer did. "Verify the simulation actually took effect" only ran for mask
mode, which left the trace leg with nothing checking its own instrumentation.
Call git explicitly after sourcing the environment and require it to appear in
the trace, then truncate the file so the self-test entry does not count against
the install. The call has to be explicit because macOS reaches _has_working_git
only under STUDIO_LOCAL_INSTALL (install.sh:2026), so no consumer leg on that
platform probes git on its own.
* Stop the Windows clean-machine check failing on its own probe exit code
All three Windows legs failed "Verify the simulation took effect" with no
::error:: printed at all. The check itself was right: the mask step logged
"masked toolcache python: C:\hostedtoolcache\windows\Python", python/git/cmake/cl
were ABSENT, no `py -3.x` probe started an interpreter, and the winget assertions
were satisfied. The step still exited 1.
The cause is $LASTEXITCODE leaking out of the step. The last external command is
the `py -3.13` probe, which is SUPPOSED to fail; Get-Command and Write-Host are
cmdlets and never reset $LASTEXITCODE, and the runner appends
`if ((Test-Path -LiteralPath variable:\LASTEXITCODE)) { exit $LASTEXITCODE }`
to every pwsh step (actions/runner#351). So a clean machine reported failure,
and because this step runs before Install, no Windows leg has ever reached the
installer. Clear $LASTEXITCODE after the probe loop and end with an explicit
exit 0. The leak detection is untouched: a surviving python/git/cmake/cl, or a
`py -3.x` that actually starts, still exits 1.
Also print each probe's exit code and output, so the next failure here explains
itself instead of being silent, and label `py -0p` as what it is. The launcher
reads the registry, which the on-disk toolcache rename cannot rewrite, so -0p
keeps naming paths that no longer exist. Unlabelled it reads like a leak.
Accept the Fedora leg's real outcome instead of a message that can be absent
The fedora assertion only accepted the unsupported-package-manager hard exit.
That is still what this ref's install.sh does, but the pending installer change
replaces it with a warning that lets the install continue, at which point the
old grep matches nothing and the step fails for the wrong reason.
Handle both, strictly. If the log shows the newer "using prebuilt llama.cpp
(missing:" warning, the Linux gate demonstrably did not hard-stop, and the only
tolerated failure past that point is release lag: install.sh comes from this ref
while unsloth comes from PyPI, and the released studio/install_python_stack.py
has no "skip triton kernels when git is missing" guard, so it still fetches the
git+https triton_kernels requirement on a machine with no git. Anything else
after that warning fails the step. Otherwise the old hard-exit message is still
required. A missing log, a bootstrap outage or any unrecognised failure all
remain errors, and the step retires to a plain success assertion once a release
ships the no-git skip.
* Make the AppImage Linux row actually extract, and hold Linux to the macOS preflight bar
The appimage row invoked the extractor by bare filename, and a command word
with no slash is resolved through PATH rather than the working directory, so
the extraction exited 127 and the bundled-installer assertion below it never
ran. Prefix it with ./ so the row exercises what it claims to.
The Linux log step also asserted nothing: it skipped a missing log with
continue and discarded the grep with || true. The launch step only proves the
process stayed alive for 90 seconds, and the bundled-installer checks do not
exercise the Rust preflight path, so an app that hung before preflight
completed passed both required Linux rows. Require the same
desktop_preflight completed disposition= record the macOS rows already do.
* Put the branch's own Python under test on the clean-machine legs
install.sh and install.ps1 come from the ref under test, but they install
unsloth from PyPI, which is the consumer path and has to stay that way. That
left everything Python-side coming out of the released wheel: studio/setup.sh,
studio/setup.ps1, studio/install_python_stack.py, and every requirements and
constraints file those resolve through Path(__file__). A branch that changes
constraints.txt or setup.ps1 therefore got a green run that proved nothing
about the change, and some legs proved less than they looked. The Fedora
assertion was already carrying a hand-written workaround for exactly this,
tolerating a triton/git failure on the grounds that the released package lags
the ref.
Legs marked overlay: true now re-point the venv at the ref just before studio
setup runs, through UNSLOTH_CI_SOURCE_OVERLAY: a --no-deps editable install of
the checkout. That makes import studio resolve to the working tree, so the
existing setup-script lookup finds the ref's setup.sh / setup.ps1 and
install_python_stack reads the ref's constraints, with no other change to
either installer.
Not --local: --local additionally installs unsloth-zoo from a git+https URL,
which genuinely needs git, and git absence is the whole point of the masked
legs. The overlay resolves no dependencies and clones nothing, so it holds up
with git, cmake and the compilers all gone. It is not a consumer knob either:
no flag, no usage entry, ignored unless the variable names a directory with a
pyproject.toml in it.
Four legs stay on the released package deliberately, each for its own reason,
recorded in the header: the mac pipe legs keep an end-to-end signal on what a
user actually runs; the trace leg would otherwise answer its own question,
since the editable build calls git through setuptools-scm's file finder; the
non-root Linux leg dies before a venv exists; and WSL only ever receives
install.sh, not a source tree.
Two supporting fixes the overlay depends on or exposes:
install_python_stack.py discarded uv's output whenever a step succeeded, so
the nobuild assertion, which reads the install log, could not see a source
build in the dependency phase at all. That is the phase that installs
studio.txt, where an sdist-only dependency actually turns up, and it reported
"built: none" regardless. It now echoes successful output under
UNSLOTH_VERBOSE, matching what install.sh's run_install_cmd already does.
nobuild now ignores "Building <name> @ file://" lines. A local-path build is
something the caller pointed at, never a dependency resolution chose, and
index dependencies always print <name>==<version>, so a real sdist from PyPI
is still caught, including one named unsloth.
Each overlaid leg also asserts it really was overlaid, so an unset variable
cannot quietly put the whole matrix back on the released wheel.
* Allowlist the triton-kernels pure-Python sdist, and record why Windows on ARM is red
The two ubuntu2404 root legs went red at "Assert no source build" reporting
triton-kernels. That is not a regression in what the installer does. Those
builds have always happened; they only became visible now that pip_install
stopped discarding uv's output on success, which is what finally let the
nobuild check read the dependency phase at all.
So the question was whether each build actually needs a compiler. Checked
against the real artifacts rather than assumed:
openai-whisper 20250625, randomname 0.2.1, argbind 0.3.9 -- no version of
any of the three has ever published a wheel; antlr4-python3-runtime is
pinned at 4.9.3, below the first release that ships one. All four sdists
use setuptools.build_meta, declare no ext_modules, and contain no
.c/.cpp/.pyx/.rs file. Already allowlisted, correctly.
triton-kernels is the same category and was the only name failing. It is
pinned to the triton repo's python/triton_kernels subdirectory; that tree
is 75 files of Python, a four-line pyproject.toml, no setup.py and no
native source at all. The kernels are Triton DSL compiled at runtime, not
at install time. It is also a direct URL the installer names itself rather
than something resolution picked, and only Linux reaches it. It belongs in
the allowlist, so add it with that reasoning written down.
The allowlist match is now lowercased and underscore-folded on both sides.
The requirement spells the package triton_kernels while uv prints
triton-kernels, and an allowlist that matched only one spelling would pass
by luck rather than by intent. A plain pyarrow sdist is still caught.
The two data-designer @ file:// plugin builds needed nothing: they are
in-tree local paths, already dropped by the same rule that exempts the
source overlay's own build.
Separately, the windows-11-arm leg fails for a real reason and should keep
failing. The ARM handling itself works, the log shows torchaudio being
skipped and torch plus torchvision installing from wheels. What stops it is
that pyarrow and hf-transfer publish no win_arm64 wheel at all, so uv falls
back to their sdists and they fail on CMake configure and on openssl-sys
wanting perl. That is a product gap on the platform, not a gap in the
simulation, so the leg stays experimental and keeps reporting it. Record
that above the matrix entry so the next reader does not re-diagnose it.
* Exercise the bundled Windows installer, and stop mislabelling installer sources
Four things that let a leg go green while proving nothing.
The desktop Windows job installed the bundle and launched it, and that was all.
On a fresh profile preflight reports not_installed and the app sits on the
install screen waiting for a click, so the process happily stays alive for 90
seconds without the bundled install.ps1 ever running. A bundle that shipped no
install.ps1 resource, or a broken one, passed this job -- which is the packaged
app failure the workflow exists to catch. macOS and Linux already invoke their
bundled script directly; Windows now does the same, via the resource NSIS laid
down next to the exe, invoked the way install.rs invokes it, then asserts the
managed venv exists and can import torch. Its timeout goes to 60 minutes
because a full torch install on a Windows runner is the slowest of the three.
A manual run that selects installer_source: published only redirected the macOS
and Linux jobs. WSL kept copying the checked-out install.sh and Windows kept
running the checked-out install.ps1, so a run asking whether the script on
unsloth.ai works reported on this ref under the published label. Both now honor
the selection; install.ps1 advertises its own unsloth.ai URL, so published has a
meaning on Windows too. Both branches stay empty on pull_request and push, so
automatic runs are unchanged.
The push-to-main filter listed only install.sh, install.ps1 and this workflow,
while the PR filter also covers setup.sh, setup.ps1, install_python_stack.py and
the clean-machine helpers. A direct push touching those skipped the workflow
entirely, so the post-merge backstop never ran for the files the source overlay
was added to cover. The two lists now match.
Neither filter covered studio/backend/requirements, even though the overlay
exists precisely so a constraints change is resolved on a machine with no
compiler and no cached wheels. The update-smoke workflows cannot stand in: they
start from a preinstalled Python and full developer tooling.
* Make the Linux and Windows desktop legs clean, and honour published on every macOS delivery
The desktop workflow claims all three platforms are stripped, but only macOS
and Windows had a strip step and the Windows one scrubbed the process PATH
only. Both gaps let a bundle that needs a developer toolchain pass the one
workflow whose premise is that it must not.
Linux: the job ignored strip_toolchain entirely and ran the bundled install.sh
with the runner's git, gcc, cmake and make in /usr/bin. clean-machine-env.sh
now has a Linux --remove branch that moves the resolved tool binaries aside,
recorded in restore.sh, and the job calls it plus `assert absent` after the apt
step (the .deb install needs dpkg) and before the bundled installer, with a
restore step to match macOS. The loop repeats per tool so a name present in
both /usr/bin and /usr/local/bin is fully masked rather than half masked.
Windows: rewriting $env:PATH does not survive the bundled install.ps1, which
calls Refresh-SessionPath (318-337) and rebuilds $env:Path from the Machine and
User registry values, and py.exe in C:\Windows reaches the toolcache whatever
PATH says. Ported the on-disk toolcache rename, the Machine/User registry scrub
and the py -3.11/-3.12/-3.13 start probe from clean-machine-install-ci.yml, so
the strip is proven rather than assumed.
Windows preflight: the log step was Test-Path, Get-Content and Select-String,
none of which can fail, so an app that hangs before preflight passed on the
90 second liveness check alone. It now asserts a tauri.log exists and carries a
`desktop_preflight completed disposition=` line, the same unconstrained check
macOS and Linux already make. The disposition VALUE is deliberately not
constrained: ManagedReady over an unbootable venv is the reported bug.
installer_source on macOS: only the pipe delivery branched on it, so a
`published` dispatch ran the checked-out script on six of the eight macOS rows
while the run was labelled published. The script is now resolved once at the
top of the Install step and used by the file and tauri deliveries; pipe still
re-fetches through the live transport, because that is half of what it tests.
Linux, WSL and Windows already honoured the input.
Also shortened the comments across the changed files, keeping the reasoning
that says why each check exists.
* Run the Windows installer under PowerShell 5.1, the only shell a clean machine has
The Windows Install step ran `& $script` inside a `shell: pwsh` step, so
install.ps1 was executing under PowerShell 7. A genuinely clean Windows box
does not have PowerShell 7: Windows ships powershell.exe (Windows PowerShell
5.1) and pwsh is a separate install that the hosted runner image happens to
preinstall. So the one workflow whose premise is a machine that has never seen
a developer toolchain was testing the installer under a shell that machine
would not have, and no other Windows job anywhere in .github exercises
install.ps1 under 5.1.
Invoke it the way the desktop does (install.rs:325-339, and the bundled
installer step in desktop-app-clean-machine-ci.yml): powershell.exe with
-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File. The pwsh
step wrapper stays, since it is only the installer that has to be under 5.1.
Calling powershell.exe with `&` keeps the output in the pipeline, so
Tee-Object still fills logs/install.log, and $LASTEXITCODE after the pipeline
is the child's real exit code, so $rc and `exit $rc` are unchanged.
install.ps1 and studio/setup.ps1 hold no PowerShell 7-only constructs: no
`#Requires` above 5.1, no `&&`/`||` chain operators, no ternary, no
null-coalescing, no ForEach-Object -Parallel, no $IsWindows/$PSStyle, and no
6+ cmdlets or parameters. setup.ps1 declares `#Requires -Version 5.1`, and its
three $PSVersionTable branches gate a 7-only preference on the 7 side with a
5.1 fallback. Every Invoke-WebRequest already passes -UseBasicParsing, which
5.1 needs because it otherwise reaches for the IE engine.
* Assert the Windows desktop strip actually took effect
The desktop job's Windows masking renamed the toolcache Python, scrubbed the
Machine and User registry PATH, and probed `py`, but nothing checked that
`python`, `git`, `cmake` or `cl` were gone. The drop list is heuristic path
fragment matching, so a runner image that moves any of those outside those
fragments leaves the bundled install.ps1 reusing hosted developer tooling while
the job still reports a clean machine. PATH written to $GITHUB_ENV only applies
to later steps, so the check has to live in a step of its own; it carries the
same event gate as the strip, exempts `py` (it lives in C:\Windows and stays,
which is why the start probe is the real evidence), and resets $LASTEXITCODE
before exiting 0 so an intentionally failing probe cannot fail a clean machine.
Also correct the no-winget matrix note: that leg is not failing for an unfixed
product reason. It stops at the unconditional git gate in setup.ps1 only on this
ref, and with that gate relaxed it passes along with every other leg, so the row
is a merge order dependency and stays required.
* Resolve the desktop release including drafts, the convention this repo ships
All three desktop legs died at the download step with an empty REL_TAG. The
resolver passed --exclude-drafts while REL_REPO now defaults to
github.repository, and every desktop-v* release in unslothai/unsloth is a draft:
desktop-v0.1.50-beta and desktop-v0.1.471-beta are both drafts carrying the .dmg,
.deb, .AppImage and setup.exe, while only the non-desktop tags like v0.1.501-beta
are published. Excluding drafts therefore matched nothing and no leg could ever
run against a production bundle.
Drop --exclude-drafts so the newest desktop-v* release is found. A draft has no
tag ref, so releases/tags/<tag> 404s for one, but gh resolves drafts over GraphQL
and gh release download <tag> fetches their assets normally, so the download call
is unchanged. Listing drafts requires push access, which for GITHUB_TOKEN means
contents: write, so the workflow permission is raised from read and annotated.
When nothing resolves the leg still fails hard rather than skipping: with no
bundle to install there is nothing to prove, so a green run would be a lie. The
error now names both causes, no release cut yet or a token that cannot see drafts.
Also stop the restore step swallowing its own failure. `bash
.clean-machine/restore.sh || true` printed "No such file or directory" whenever an
earlier step failed before the toolchain was stripped, and hid a genuinely broken
restore just the same. Skip explicitly when the file is absent and let a real
restore failure surface. Same fix in clean-machine-install-ci.yml, which had the
identical line.
* Skip the desktop jobs on fork PRs instead of failing them
Every desktop-v* release in this repo is a draft, and GitHub lists drafts only
to a token with push access, which is why resolving one needs contents: write.
A pull request from a fork receives a read-only token no matter what the
workflow declares, so on those runs the resolver cannot see any release and the
job died on "no desktop-v* release visible", accusing the repo of having no
bundle when the real cause is the trigger.
This workflow runs on pull_request for changes to itself and the stripping
scripts, so an outside contributor editing either would have hit that. Guard the
three jobs on the head repo not being a fork. A skipped job is honest here: it
does not claim to have tested a bundle it was never able to download, and it is
not reported as a pass.
* Close the free headroom in the clean-machine simulation
Assert arch and signature on every downloaded Mach-O. This is the one genuine
gap the simulation had: Rosetta 2 is preinstalled on hosted runners and absent
from a factory-fresh Mac, so an x86_64-only llama.cpp, whisper.cpp, Node or uv
payload runs green here and dies with "bad CPU type in executable" for the
user. llama-server launching under `assert-llama-loads.sh` does not rule that
out, because Rosetta makes it launch. The new `macho` check reads `file -b`
(`lipo` is an xcrun shim and is gone after masking, as the desktop lane already
notes) and keys the expected arch off `uname -m`, so macos-15-intel expects
x86_64. It also requires at least an ad-hoc signature on arm64, which closes
the AMFI "Killed: 9" class that uv has already been bitten by; the check is
skipped on x86_64, where unsigned code loads fine and so is not the same
defect. It fails when the scan finds nothing, since an empty scan reads exactly
like a clean one.
Make absence real rather than PATH-hidden. uv probes well-known interpreter
locations and the framework loader ignores PATH entirely, so hiding the
toolcache only hid it from `command -v`. Empty /usr/local (it EXISTS on a
factory-fresh Mac as a SIP-exempt firmlink, and is empty; it is /usr/local/bin
that is absent, so the directory itself stays), move the hosted toolcache and
/Library/Frameworks/Python.framework aside, and clear the developer dotdirs and
caches. A populated uv or pip cache can also satisfy a resolution that would
fail on a user's machine. Every removal goes through --remove and is recorded
in the generated restore.sh, guarded so a path the install recreated is not
buried inside its own restore.
Unset CI, GITHUB_* and RUNNER_* for the installer process only. An installer
branching on CI=true is a hidden dependency no consumer exercises. Scoped to
the child so the step's own $GITHUB_OUTPUT still resolves.
Record spctl --status and csrutil status. Neither is documented for these
images and both change what a binary is allowed to do.
* Pin the two failures no change here can fix, and add the virgin Windows container lane
Three red checks, two of which test something this branch does not own.
desktop linux deb / appimage run the SHIPPED bundle's own install.sh, and
desktop-v0.1.50-beta was cut on 2026-07-21, before #7547 merged on 07-29. That
bundle still carries the old optional-dependency gate, so on a stripped runner it
exits 2 at [TAURI:NEED_SUDO] cmake git build-essential libcurl4-openssl-dev and
never creates a venv. Current main's _check_linux_deps runs the same set through
_SMART_APT_OPTIONAL, which suppresses every escalation path, so only a new release
can change this. The step now pins that exact outcome: the exit code must be 2 and
the log must carry exactly that package list, anything else still fails, and
finding _SMART_APT_OPTIONAL in the extracted install.sh (the guard #7547 added)
turns into a hard error saying to delete the pin. The venv and torch assertions
stay and still run whenever the installer succeeds.
win windows-11-arm gets a native ARM64 CPython, and torchaudio publishes no
win_arm64 wheel at any version, so the PyTorch step cannot resolve. The fix is in
install.ps1 on #7549, still open. Same treatment: the Install step is
continue-on-error and a new step requires all three of the PyTorch step, the
torchaudio resolution error and the missing win_arm64 platform tag, so any other
failure is red. The row leaves experimental so the job is required, and the pin
errors out as soon as the venv interpreter reports anything but win-arm64, which
is what #7549 landing looks like.
Adds the virgin Windows container lane as two jobs here rather than a sibling
workflow: same premise as the win legs, same path filters, and masked-versus-real
reads better side by side. The hosted Windows legs cannot test the VC++
2015-2022 runtime (it ships in the runner image's System32) or a Windows with no
Microsoft Store, and a servercore:ltsc2022 container on windows-2022 answers both.
The probe asserts no python, py, git, cmake, cl, winget or uv on PATH, on disk or
in the registry, and now also asserts vcruntime140.dll, vcruntime140_1.dll and
msvcp140.dll are absent, which is the one thing the hosted runner cannot un-ship.
Both container install rows stop at studio/setup.ps1's winget-only git gate on
this branch, since #7549 is what relaxes it, so both are pinned the same way. The
overlay row additionally requires the UNSLOTH_CI_SOURCE_OVERLAY hook to have
fired, unconditionally: without that it would be indistinguishable from the
released-wheel row, and the hook is this branch's own feature.
Container notes carried over from the spike: never docker pull when the image is
cached, since MCR has shipped an image ahead of the runner host before; wait for
the Docker daemon, because one leg died in 21s on npipe:////./pipe/docker_engine
and that flake misreads as "Windows containers unavailable"; drive docker from a
run: step, because the job-level container: key is Linux-only. The root CA store
is seeded after the virginity assertion, restoring what a real Windows already
has, because studio/install_node_prebuilt.py downloads Node with bare
urllib.request.urlopen and hits CERTIFICATE_VERIFY_FAILED against the empty
container ROOT store. That product bug is left alone here.
* Check signatures on Mach-O main executables only
The macho check asserted a valid signature for every Mach-O under the studio
home, and failed the macos-15 mask/pipe leg on 29 files: lxml, charset_normalizer,
cygrpc, _upb, fontTools, caio, brotli and a bundled libportaudio.dylib. Those are
MH_BUNDLE and MH_DYLIB images dlopen'd into a process without library validation,
they ship unsigned in the wheels, and the same run had already installed and
imported them with the installer exiting 0.
Key the signature half off the Mach-O filetype and run it only on main
executables. Report an absent seal separately from one that fails to verify, and
capture codesign output instead of piping it into grep, which returned the
unsigned exit status through pipefail and called every unsigned binary broken.
The architecture half is unchanged and still a hard failure: it is what closes
the Rosetta 2 gap. The zero-Mach-O guard is unchanged. The .venv_t5_* sidecars
stay in scope; setup.sh creates them during a normal install and
transformers_version.py puts them on sys.path, so they are payload.
* Make the WSL job gate, assert Windows installed no toolchain, strip before the .deb
* Assert the root Linux legs did not compile llama.cpp with the apt-installed toolchain
* Pin the macOS desktop legs on the same pre-7547 release lag
The Linux rows already pin the shipped bundle's own install.sh exiting 2 at the
NEED_SUDO handshake. macos-15 and macos-26 fail the same way for the same reason:
desktop-v0.1.50-beta predates #7547, so the bundled installer still hard-exits on
the Xcode CLT gate that #7547 turned into a warning.
Accept exit 1 plus that exact gate line, and nothing else. _check_macos_deps is
the function #7547 added, so its presence in the bundle means the release caught
up and the block errors out asking for the pin to be deleted.
* Pin the WSL pipe truncation and the masked-winget git gate
The WSL leg dies at install.sh:2082 with an unterminated quoted string.
Nothing is wrong with that line: piping the script into sh is not atomic.
dash reads it from the pipe in 8192-byte blocks and runs each command as
it parses, and install.sh:2007 calls _maybe_reroute_strixhalo_to_2404,
which on WSL alone shells out to Windows interop; interop relays the
stdin it inherited and drains the pipe. dash has 11 blocks buffered at
that point, ending at byte 90112, which falls inside
"$STUDIO_LOCAL_INSTALL" on line 2082. Truncating install.sh at 90112
and parsing it reproduces the message verbatim, and running the whole
file under a stdin-draining interop stub reproduces the exit code too.
#7548 wraps the body in _unsloth_main so sh parses everything before
running anything, and the same reproduction against its head is clean.
The eight green staging runs cited when this job's continue-on-error came
off were all on trees that already carried #7548, so that evidence never
covered this branch. Pin the exact signature instead: exit 2 plus the
shell's own unterminated-quoted-string error, with the _unsloth_main
marker read back out of the distro as the flip condition.
Pin winget=masked the same way. studio/setup.ps1:1655-1669 gates on git
unconditionally and can only fetch it through winget, so masking winget
leaves no way to satisfy it. #7549 relaxes the gate, and its wording
appearing in the tree retires the pin.
* Retire the WSL pipe pin now that #7548 is in main
The pin flipped exactly as designed: it looks for _unsloth_main in the installer
it actually ran, and #7548 put it there. Delete the pin and the CLI waiver, and
assert the opposite instead.
WSL is the only platform whose install shells out to Windows interop mid-script,
and interop relays the stdin it inherited, so this job is the one that can catch
the pipe being drained again. A truncation here is now a hard failure.
* Gate the no-elevation Linux install and split off the no-transport case
* Assert no source build on the hosted Windows legs and keep winget for the desktop lane
* Retry the container root CA seeding instead of failing on one Windows Update timeout
* Run the clean-machine workflow for the prebuilt installer helpers it overlays
* Narrow the container pin to its own gates and scan uv and the venv interpreter for arch
* Tighten the clean-machine comments
* Re-assert toolchain absence after the desktop .deb pulls its dependencies
* Retire the #7549 pins and add a wget-only Linux leg
#7549 is in main, so the three known-outcome pins that were waiting on it are
stale and would now hard-error by design. Each is replaced by the assertion it
was standing in for rather than deleted:
win windows-11-arm now gates. The x64-on-ARM64 resolver is asserted as an
outcome: the venv interpreter reports win-amd64 from its own sysconfig, and
torchaudio (no win_arm64 wheel at any version) is installed. Measured on the
integration branch before #7549 merged: "only a native ARM64 Python 3.13 was
found" -> "installing x64 Python" -> torchaudio 2.10.0+cpu, install green.
win windows-latest / winget=masked now gates. The relaxed git gate is asserted
from both sides: the old unconditional message must be absent, the no-git
branch must have been reached (so the row cannot pass because git leaked back
onto PATH), and setup.ps1 must report git as absent-but-not-required.
Both Windows rows, and the visible one, gained the usability check the Linux
legs have had and Windows never did: a managed interpreter, an unsloth CLI on
disk, and that CLI actually running. nobuild and the toolchain check only read
the log, so an installer that exited 0 having produced nothing satisfied them.
The torch assert also loses its fallback to whatever `python` resolves to.
The virgin container overlay row gates, and asserts what only that lane can:
it is the one environment whose System32 does not already ship the VC++
2015-2022 runtime, so it is the only place Ensure-VCRedist's direct aka.ms
download can be proved to run rather than be short-circuited. The overlay=false
row keeps a pin, with a new reason: it installs unsloth from PyPI on purpose,
and setup.ps1 inside 2026.7.5 (uploaded the 23rd) predates #7549, so it still
stops at the old gate. That is release lag, it flips on the next release, and
the pinned signature is now the old wording rather than "#7549 has not landed".
Also adds linux ubuntu2404-nonroot-wget. install.sh's download() takes curl or
wget and _transport_missing is true only when both are gone, so a wget-only box
is supported on paper, but the gating nonroot leg provisions ca-certificates
AND curl, so curl won every probe and the wget branch had never run. Same image,
same no-sudo user, same asserts, wget instead of curl, and curl proved absent on
disk for root and for tester before AND after the install, so the claim is that
every download went through wget rather than that curl happened to be unused.
* Tighten the clean-machine CI comments
Comments only, no assertion logic, pins or leg definitions touched.
Reflowed every rationale block to denser wording and removed the
duplication that had built up across repeated steps: the desktop
workflow repeated the fork-PR skip, the desktop-v* tag resolution and
the restore-runner note once per platform, and the installer workflow
repeated its path-filter rationale in both the pull_request and push
blocks. Those now point at the first copy.
Every WHY is kept: why the masked legs avoid install.sh --local, what
UNSLOTH_CI_SOURCE_OVERLAY is for, why `absent` tests "must not work"
rather than command -v, why the .venv_t5_* sidecars are in the macho
scan scope, why the signature check is main-executables-only, why each
nobuild allowlist entry is a pure-Python sdist, why the WSL job gates
and what the pipe truncation was, and why the virgin container's
overlay=false row is still pinned.
Proved comments-only three ways: both workflow revisions parsed with
yaml.safe_load_all and every leaf walked (only `run:` scalars differ);
every changed bash body and .sh compared byte-for-byte after
`bash --pretty-print -n`; every changed pwsh body and .ps1 compared as
a token stream with Comment and NewLine tokens dropped. A negative
control injecting one non-comment line into each layer makes all of
them fail.
* Clean machine CI: strip Strawberry, make the Fedora pin gating, run the Linux CLI
desktop windows failed the strip verification because windows-latest ships a MinGW
toolchain under C:\Strawberry\c\bin, which matches none of the drop fragments; the
installer workflow already scrubs it.
Fedora sat behind job-level continue-on-error, so its outcome pin could not fail the
run. Tolerate the install step instead, as the no-transport row does.
The Linux usable-install check only tested the executable bit; Windows and WSL already
execute the CLI. The macho scan now fails when no venv interpreter was scanned, rather
than letting uv alone satisfy the outside-root guard.
* Clean machine CI: tighten the comments
Round 12 comment reduction: compress wording, keep every reason. Comments only,
verified with a YAML leaf walk (differences only inside run: scalars, only on # lines),
bash --pretty-print -n byte comparison, a PowerShell token-stream diff and a Python AST
comparison.
* Clean machine CI: dereference the venv interpreter, pin the deb deps and the Windows disposition
file did not follow the <venv>/bin/python symlink find -L printed, so it answered
'symbolic link to ...' and the Mach-O test dropped the one interpreter the Rosetta scan
exists to check. Read with file -Lb and count what was classified, not what was found.
apt treats a toolchain package the strip only renamed as already installed, so a .deb
that started declaring git or cmake would never restore it and the absent re-check would
still pass. Assert the declared Depends instead.
The Windows lane accepted any preflight disposition although the bundled installer was
already required to build a working venv; NotInstalled or ManagedStale there means the
app cannot boot what it just installed.
* Clean machine CI: assert every masked tool, and re-select the developer dir last
clean-machine-env.sh moves ten tools aside and only warns when a move fails, but absent
checked four of them, so a surviving gcc -- which install.sh probes for build-essential
-- went unnoticed.
restore.sh ran xcode-select --switch before the line that moved CommandLineTools back,
so it named a still-masked directory, failed into || true and left the selection link
unrestored. Capture the original selection and re-apply it after both directory
restores.
---------
Co-authored-by: danielhanchen <unslothai@gmail.com>
|
||
|
|
52609fb890
|
Studio: reset-password rotates the credential in place instead of deleting auth.db (#7573)
* reset-password: rotate the admin credential in place instead of deleting auth.db * reset-password: fix the CI callers and error handling for the in-place rotation * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * reset-password: narrow the CI change to the jobs that read .bootstrap_password * reset-password: stop over-claiming what the reset revokes and when it takes effect * auth: bind token issuance to the credential version that was verified * auth: bind credential-creating writes to the version the request authenticated with * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * auth: bind the change-password and workflow-key writes to their own credential version * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * auth: read the credential version inside the transaction that validated it * data-recipe: answer 401 when a reset revokes the credential mid job start * Fix lint blocker and Windows path assertion for PR #7573 Drop the now-unused validate_api_key import from studio/backend/auth/authentication.py. Every call site moved to validate_api_key_with_credential, so the Source lint job's import-hoist gate flagged it as a blocker. The wrapper itself stays in storage.py; test_api_key_expiry.py still exercises it. Make test_run_reexec_forwards_resolved_frontend_on_public_launch compare against str(Path(...)) instead of a POSIX literal. _find_frontend_dist returns a Path, so on Windows the forwarded value is \fake\studio\frontend\dist and the assertion could never pass there. Pre-existing, surfaced by running unsloth_cli/tests on Windows. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han <danielhanchen@gmail.com> |
||
|
|
2916e84499
|
Studio: clarify tool permission controls (#7181) | ||
|
|
03590f696e
|
Give opencode real timeout headroom in Local Agent Guides CI (#7235)
* Raise the opencode invoke timeout in Local Agent Guides CI
The connection (opencode) cell flakes with a 600s timeout reported as guide drift, but it is not a hang: in a passing run the same opencode run finishes in ~482s (08:12:31 to 08:20:33), right against the shared AGENT_INVOKE_TIMEOUT of 600s, so about one run in six drifts past the cap.
opencode is the slow outlier. The print-mode agents (claude -p, codex exec) run one turn against a minimal injected system prompt, while opencode run runs its own full turn with opencode's large system prompt plus a separate small_model call to name the session (start.py pins small_model to the same 4B the server hosts). On a CPU-served gemma-4-E4B that is about 8 minutes, leaving no margin under 600s.
Double opencode's per-invoke timeout in agent-guides-drive.sh and keep the tight 600s cap for the fast agents, so a genuine headless-TTY hang still fails quickly. 1200s stays well under the 40-minute job budget.
* Normalize the agent invoke timeout before doubling it for opencode
Strip an optional trailing 's' from AGENT_INVOKE_TIMEOUT so the opencode
arithmetic, and the "${TIMEOUT}s" timeout message, stay valid if a
timeout(1)-style suffix is ever configured.
* Only double the opencode timeout for a bare-integer seconds value
Guard the arithmetic so a GNU timeout(1) duration suffix (s/m/h/d, including
floats like 0.5s) is passed through unchanged instead of breaking the
expansion; timeout(1) parses those directly. Bare seconds still double.
---------
Co-authored-by: danielhanchen <unslothai@gmail.com>
|
||
|
|
6d8c18cd1a
|
Replace standalone Studio wording with Unsloth (#7221)
* Replace standalone Studio wording with Unsloth Replace the single word Studio with Unsloth wherever it is used as shorthand for Unsloth Studio in docs, CLI output, UI strings, i18n locales, workflow display names, comments and docstrings. Kept unchanged: the full name Unsloth Studio, third party product names (LM Studio, Visual Studio, Mac Studio), feature names (Recipe Studio, Fine-tuning Studio and its translations), and all identifiers such as env vars, commands, paths and filenames. * Address review feedback on the Studio wording rename Use "an" before Unsloth where the rename left the article as "a". Restore the split brand where Unsloth and Studio render as two halves of the full product name: the onboarding sidebar subtitle and the IPv6 localhost warning. Scope two messages to the full name Unsloth Studio where plain Unsloth was misleading: the AMD README bullet and the CLI studio setup error. |
||
|
|
c1e06e9ddf
|
unsloth start: add --persist to keep and reopen agent sessions (#7014)
* unsloth start: add --resume to persist and reopen agent sessions `unsloth start <agent>` launches a coding agent whose home is a throwaway temp dir wiped on exit, so codex/openclaw/hermes/pi (which relocate their whole home there) cannot resume a conversation after you quit. opencode and claude keep their session data in a fixed user dir, so they already resume. Add an opt-in --resume/--no-resume flag: it routes the launch to the stable Unsloth agents dir (the same one --no-launch already uses) so the session survives the exit, never touching the user's own ~/.<agent>. A bare --resume also reopens the last conversation via the agent's native flag (codex `resume --last`, opencode/claude/pi `--continue`). The default is unchanged: a plain launch still uses a temp dir and persists nothing. Add a dispatch-only `resume` job to the Local Agent Guides CI that drives the real launch path and asserts the split: codex/pi are wiped without --resume and persist with it, while opencode/claude persist either way. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * unsloth start: rename --resume to --persist The session flag collided with agents' own resume flags. `unsloth start claude --resume <id>` used to forward `--resume <id>` straight to Claude (which keeps its history in ~/.claude regardless), so a boolean --resume on unsloth start would have swallowed the session id and turned it into a stray prompt. Name the persistence flag --persist instead, so every agent's native resume flag (claude --resume <id>, codex resume, opencode --continue, ...) still passes through untouched. Behavior is otherwise identical: --persist keeps a launched agent's session under the Unsloth agents dir, and a bare --persist reopens the last conversation. Add a regression test that `--resume <id>` passes through verbatim, and in the CI resume experiment skip the redundant second pass for opencode/claude (they persist either way, and a second CPU turn only risks a timeout). * unsloth start: correct --persist help and drop the buggy auto-resume Reword the --persist help to be accurate: claude and opencode keep sessions in the user's own stores and resume regardless, so --persist only stabilizes the otherwise-ephemeral relocated home of codex/openclaw/hermes/pi. Drop the bare-launch auto-append of native resume tokens: it errored on a first launch with no prior session, and was inconsistent between launch and no-launch. --persist now only keeps the session dir; resume via the agent's own command (e.g. `unsloth start codex --persist resume`), which now finds it. In the CI resume experiment, fail the pass when the launched turn exits non-zero, so a write-then-error is not misread as PERSISTED. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> |
||
|
|
6ef0936180
|
Fix OpenClaw start default to local TUI (#6937)
* fix: launch OpenClaw local TUI by default * Fix/adjust OpenClaw launch paths for PR #6937 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Default OpenClaw to the local TUI only on a bare invocation The first-arg startswith('-') branch rewrote passthrough globals into a broken command: OpenClaw's grammar is openclaw [--dev] [--profile <name>] <command>, so 'unsloth start openclaw --profile test' became 'openclaw tui --local --profile test', but tui does not accept --profile (or --dev), so the invocation failed. A leading '--flag value' is ambiguous between a global (--profile test) and a tui option (--message hi), so it cannot be reinterpreted safely. Default to the local TUI only when no passthrough args are given, and forward everything else verbatim so OpenClaw parses it under its own grammar. The bare-launch default (the point of this change) is preserved; explicit subcommands and global flags pass through. --------- Co-authored-by: wasimysaid <112766706+wasimysaid@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen <danielhanchen@gmail.com> Co-authored-by: Wasim Yousef Said <wasimysdev@gmail.com> |
||
|
|
69f8e0b228
|
Clear stale yolo approval state on no-launch reruns (#6868)
* Clear stale yolo approval state on no-launch reruns The no-launch session config dir is deliberately reused across runs, but the config writers only ever added the --yolo auto-approval settings and never removed them. After one --yolo --no-launch run, every later run without --yolo kept OpenClaw's tools.exec security=full/ask=off policy plus exec-approvals.json, and OpenCode's permission allow block, so tool execution stayed silently pre-approved. Non-yolo runs now reset that state: OpenClaw drops the exec policy keys and the yolo defaults in exec-approvals.json (approvals OpenClaw itself recorded are kept; the file is removed when only the yolo payload is left), and OpenCode drops the permission block. Launch mode is untouched since it already uses an ephemeral temp dir. * Strip only yolo-written values on non-yolo cleanup Match each field against the exact value the yolo path writes before removing it, so a stricter exec policy, approvals defaults set by the user or the OpenClaw UI, and deny/ask OpenCode permission entries all survive a plain no-launch rerun. An unparseable exec-approvals.json is left in place, matching how an unparseable config is handled. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Write a prompting policy on non-yolo instead of deleting to a permissive default OpenClaw and OpenCode both treat an omitted policy as permissive: OpenClaw's effective exec policy for an unset tools.exec is security=full/ask=off on the gateway host, and OpenCode defaults an unset permission to allow. So clearing the yolo values on a non-yolo run did not restore prompting, it fell back to those permissive defaults and left tool execution auto-approved. A non-yolo run now writes an explicit prompting policy: OpenClaw gets security=allowlist/ask=on-miss (verified to prompt even with the approvals file removed, since the stricter of config and approvals wins), and OpenCode gets edit/bash/webfetch=ask. Only a permissive/yolo value is tightened; a stricter deny (or an ask the user set) is preserved, and the yolo approvals defaults are still stripped. The file-edit CI path opts opencode/openclaw into --yolo, since those agents now prompt by default and the headless test needs auto-approval. * Respect existing exec mode, sandbox/node host, and global permission rules on non-yolo reset The non-yolo reset for openclaw/opencode assumed an omitted policy was the permissive yolo default and rewrote it, which corrupted or weakened stricter setups it should have preserved: - OpenClaw tools.exec.mode is the normalized policy knob and cannot be combined with explicit security/ask (OpenClaw rejects the whole config), so writing security+ask alongside a mode:deny/ask policy both broke the config and relaxed it. Leave a mode-based policy untouched. - host=sandbox defaults to security=deny and host=node routes to a paired node; neither is written by --yolo (which only writes host=gateway). Treating the missing security as full and popping host broadened those into gateway/auto exec. Only rewrite a gateway-routed permissive policy, and never pop a non-gateway host. - OpenCode permission can be a string ("deny") or a {"*": ...} catch-all. The old code dropped a string form and overrode a catch-all by writing per-tool ask, weakening a stricter user rule. Now a string is left in place, a catch-all governs absent tools, and only an effective allow is tightened. - The non-yolo ask policy only lived in OPENCODE_CONFIG, which loads below project opencode.json, so a project config allowing edit/bash/webfetch still auto-approved. Carry the ask policy in OPENCODE_CONFIG_CONTENT (above project config) too, symmetric to how yolo carries its allow. Also harden the openclaw path against a malformed non-dict tools value. Adds tests for mode/sandbox/node hosts, string and catch-all permissions, and the inline ask policy over a project config. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Scope non-yolo resets to the exact yolo fingerprint and preserve granular denies OpenClaw: reset only the exact host=gateway/security=full/ask=off policy --yolo writes, so an omitted or host=auto/sandbox/node policy (which can resolve to a sandbox security=deny default) is no longer broadened to allowlist/on-miss, and a deliberate tools.exec.mode is left alone (OpenClaw never migrates our security/ask write into a mode). OpenCode: carry a granular object or a deny inline verbatim so a per-tool user rule is not collapsed to a blanket ask, but floor any object that grants allow anywhere to the string ask (which fully replaces a project object) so no inline allow pattern can leak through into a silent auto-approve on a non-yolo session. * Stop overriding project config on non-yolo; require full approvals fingerprint The non-yolo OpenCode reset carried a session permission in OPENCODE_CONFIG_CONTENT, which outranks the project opencode.json we cannot read. That inline override could not correctly reflect the project: it weakened a project deny to a prompt, mishandled global string rules, leaked through a granular object's permissive default when no catch-all was present, collapsed an object with an allow (losing its deny), and missed per-agent permissions. All of these stem from forcing a value over an unknown project config. A non-yolo run now only undoes what --yolo wrote: it flips our own explicit per-tool allow back to ask in our config file and carries no permission inline, so the project's own permissions are honored as written. Clearing our persisted yolo state is the actual fix; --yolo still carries its allow inline so it works over a project config. OpenClaw approvals cleanup now strips the yolo defaults only when the full fingerprint (security=full, ask=off, askFallback=full) is present, so a mixed user policy that merely shares askFallback=full (whose omitted default is deny) is kept intact. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> |
||
|
|
b8400f40df
|
CLI: Rename unsloth connect to unsloth start (#6613)
* replaced connect with start * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix * Studio: build the coding-agent command from the selected server The API keys panel showed a hardcoded `unsloth start claude`. `unsloth start` defaults to 127.0.0.1:8888 and only mints a key for a loopback server, so a non-default port or a tunnel/remote base would target the wrong server or fail to mint. Build the command from the panel base/key (and emit a key for non-loopback), matching the other snippets in the panel. * CLI: keep `unsloth connect` as a hidden alias for `unsloth start` Avoids breaking existing scripts and docs that still call `unsloth connect`. * Tests: stub _unstarted_cleanup in same-task disconnect test The test builds _SameTaskStreamingResponse via __new__, so set the attribute that __call__ now reads. * Match coding-agent command loopback check to the CLI 127.0.0.0/8 rule (#6613) * Keep unsloth_cli.commands.connect importable as a deprecated shim (#6613) * Format the new coding-agents panel strings and import per biome (#6613) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Drop the unsloth connect alias and shim; unsloth start is the only command (#6613) * Route unsloth connect to unsloth start as a hidden backward-compatible alias (#6613) * Forward unsloth run model-load flags to unsloth start (gguf-variant, context-length, load-in-4bit, tensor-parallel) (#6613) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Session-scope coding agent config in unsloth start Configure each agent for the current session instead of writing the Studio endpoint, key, and default model into the user's own config. Codex, OpenCode, OpenClaw, and Hermes get a private config relocated through their config-path env vars (CODEX_HOME, OPENCODE_CONFIG overlay, OPENCLAW_CONFIG_PATH plus OPENCLAW_STATE_DIR, HERMES_HOME). Claude Code suppresses the attribution header for the session via the CLAUDE_CODE_ATTRIBUTION_HEADER env var plus a --settings overlay, with no ~/.claude write. --launch uses an ephemeral temp dir removed after the agent exits; --no-launch uses a stable Unsloth-owned dir and prints the matching export lines. * Read relocated agent session config in Local Agent Guides CI The contract crosscheck and the openclaw/hermes patch helpers now read each agent's config from the relocated path printed by unsloth start --no-launch (CODEX_HOME, OPENCODE_CONFIG, OPENCLAW_CONFIG_PATH, HERMES_HOME) instead of fixed home paths. The Claude attribution A/B toggles the header for the session only (shipped-config HIT vs vanilla MISS) instead of editing ~/.claude/settings.json. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Skip the POSIX-only --no-launch parser test on Windows test_no_launch_output_is_parseable mirrors the #6547 bash CI parser, which greps export/unset lines and only runs on Linux/macOS runners. On Windows --no-launch prints PowerShell ($env: / Remove-Item), so the export-line assertion does not apply there. Cross-OS staging CI surfaced this. * Size Claude Code's auto-compact window to the loaded model's context Claude Code auto-compacts against its native (~600k token) window, so against a smaller local model it overflows the server's context (silent truncation) long before it compacts. Set CLAUDE_CODE_AUTO_COMPACT_WINDOW to the loaded model's real context length (the value codex/openclaw already get via model_context_window / contextWindow). Omitted when the model reports no context length. * Pin OpenCode/Hermes context window and set 90% compaction across agents Feed every agent the server-determined sequence length (the value /v1/models reports from runtime_context_length) and a ~90% compaction threshold. OpenCode: a custom-provider model with no limit defaults to context 0, which silently disables auto-compaction, so set limit.context/output and scale the compaction buffer to 10% of the window. Hermes: pin model.context_length (it otherwise falls back to a 256k default when the server's /v1/models omits the field) and set compression.threshold 0.9. Claude: add CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=90 alongside the window. Codex (model_context_window) and OpenClaw (contextWindow) already carried the window and auto-manage off it. * Add `unsloth start pi` recipe Pi was the only agent without a built-in recipe, so the agent-guides CI hand-wrote ~/.pi/agent/models.json. Add a first-class `pi` command mirroring the others: - write_pi_config writes the session-scoped OpenAI-compatible provider config (key in the config, like openclaw/opencode). - pi() launches `pi --provider unsloth --model <id>` (Pi defaults to the google provider, so the provider/model are pinned on the command line) with HOME relocated for the session. Pi has no config-dir env var and resolves ~/.pi off $HOME, so HOME-scoping keeps the user's ~/.pi untouched. Migrate the agent-guides CI off the hand-written config onto the `unsloth start pi --no-launch` path (connection + file-edit), with a crosscheck for the provider api, so the documented recipe is exercised. * Harden unsloth start for Windows and WSL agent launches Address the Codex review on PR 6613: - write_pi_config now pins the loaded contextWindow and a sane maxTokens so Pi compacts instead of overflowing a small Studio context (it otherwise assumes its 128000 default), matching the other agents. - pi() sets USERPROFILE (and HOMEDRIVE/HOMEPATH when present) alongside HOME on native Windows, where Node resolves ~/.pi via USERPROFILE rather than HOME, so the session no longer reads or writes the user's real ~/.pi. - The WSLENV bridge flags path-valued vars with /p so a Windows npm shim under /mnt receives translated paths, while scalar vars (the numeric context window) pass through untranslated. WSLENV is deduped on the bare name. - _print_env prints the launch command with PowerShell-safe quoting so the inline --settings JSON survives copy-paste on native Windows --no-launch. Add tests for the WSLENV path flagging, PowerShell quoting, the Pi context window, and the Pi USERPROFILE relocation. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Set CLAUDE_CODE_NO_FLICKER for the Claude session A local server streams in bursts, so Claude Code's full-screen TUI redraw flickers between tokens. Disable it for the session via CLAUDE_CODE_NO_FLICKER, alongside the other CLAUDE_CODE_* session env knobs. * Add a normalized --yolo flag routed to each agent's auto-approve mode It is easy to forget which agent spells "run tools without prompting" which way, so `unsloth start` now accepts all three spellings as one option (--yolo, --dangerously-skip-permissions, --dangerously-bypass-approvals-and-sandbox) and routes to the agent's own mechanism: - claude: --dangerously-skip-permissions - codex: --dangerously-bypass-approvals-and-sandbox - hermes: --yolo - pi: --approve (Pi's only approval gate is project trust) - opencode: a permission allow block in opencode.json (no CLI flag exists) - openclaw: tools.exec security=full / ask=off / host=gateway (no CLI flag exists) Because the option is parsed by `unsloth start`, the "wrong" spelling for an agent still routes correctly instead of leaking through to the agent and erroring. IS_SANDBOX is deliberately left unset for Claude so its root/sandbox safety gate still applies. Adds routing, cross-routing, and per-config tests. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix review findings: IPv6 loopback command, pi USERPROFILE under WSL, yolo guard From a 10-reviewer pass over the PR: - studio/frontend agent-command.ts: normalize bracketed IPv6 hosts. URL.hostname returns "[::1]" for http://[::1]:8888, which never matched the "::1" loopback checks, so the copied command embedded the placeholder API key for a local IPv6 server instead of the bare auto-minting command. Now [::1] is treated as loopback like the CLI's is_loopback_url, so the command matches the CLI contract. - pi(): also relocate USERPROFILE (and HOMEDRIVE/HOMEPATH) when running under WSL against a /mnt Windows shim, not just on native Windows. Windows Node resolves ~/.pi via USERPROFILE, and the WSLENV bridge translates the path, so pi no longer falls back to the user's real ~/.pi in that case. - _yolo_command_flags: use .get so a config-based agent (or a typo) yields no flag instead of a latent KeyError. Adds tests for the WSL pi USERPROFILE relocation, the yolo unmapped-agent guard, and that opencode/openclaw --yolo stays config-only (no argv flag). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix round-2 review findings: WSLENV /p upgrade, agent help text - _merge_wslenv now upgrades a user's pre-existing unflagged WSLENV entry (e.g. a bare HOME or USERPROFILE) to the path-translated form (HOME/p) instead of leaving it as-is, so a Windows agent shim under WSL receives the translated session path rather than the raw Linux path. - Generalize the `unsloth start` registration help to list all six agents (was only "Claude Code, Codex"). Adds a test for the WSLENV unflagged-entry upgrade. * Fix round-3 review findings: complete openclaw --yolo, refresh stale copy - openclaw --yolo now also writes the host approvals file (exec-approvals.json with defaults security=full / ask=off / askFallback=full) alongside the tools.exec config. OpenClaw gates tool execution on both layers (the stricter wins), so the config alone could still leave it prompting or denying. Mirrors `openclaw exec-policy preset yolo`. ask=off means nothing is ever prompted, so the runtime socket block is unnecessary. - Studio API panel copy: clarify that a local server auto-mints the key while a remote one embeds it in the command, and add pi to the swap hint. - Local Agent Guides CI: drop the stale "pi has no start.py recipe" note now that all six agents are driven via `unsloth start <agent> --no-launch`. Adds the openclaw approvals-file assertions and a no-yolo openclaw test. * start: parse claude --version with a regex so a format change does not drop optimization flags * start: offer to install a missing agent (prompt then run its install command) * start: auto-start a Studio server for --model when none is running, and stop it on exit * inference: surface an actionable message when llama-server cannot compile a tool grammar * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix review findings: kill the auto-started server tree on Windows; apply the tool-grammar message to the OpenAI passthrough too * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * start: split --model org/repo:variant so a running session is not evicted `unsloth start <agent> --model org/repo:QUANT` failed against an already-running Studio server and, worse, killed whatever model another session had loaded. /v1/models lists a loaded GGUF under its bare repo id (e.g. unsloth/Qwen3-1.7B-GGUF), so _resolve_model never matched the `:QUANT`-suffixed request. It then POSTed /api/inference/load with model_path=org/repo:QUANT, which (a) Hugging Face rejects ("Repo id must use alphanumeric chars, '-', '_' or '.'") and (b) evicts the model the other session was using, so a second 'unsloth start' in a new tmux/terminal tore down the first. Re-running the command then attached to the now-empty server, which is why it 'worked the second time'. Mirror the org/repo:QUANT -> org/repo + --gguf-variant QUANT shorthand that 'unsloth run' and llama.cpp already accept, splitting it in _connect before we match or serve. Matching now resolves against the loaded bare repo id (no spurious reload, no eviction), and any real load uses a valid repo id plus gguf_variant. An explicit --gguf-variant still wins; local paths and Windows drive letters pass through untouched. The auto-serve path likewise spawns 'unsloth run --model org/repo --gguf-variant QUANT'. * start: harden auth-key handling, codex teardown, and CI transcript redaction Three review findings: 1. CI could leak a live key. agent-guides-drive.sh printed the raw 'unsloth start --no-launch' transcript (which carries export UNSLOTH_API_KEY / ANTHROPIC_AUTH_TOKEN lines) to the Actions log on both the failure path and the success path before redact() ran. Add cat_redacted() and use it for those two prints, so the key is scrubbed on the way to the log while the on-disk file stays intact for the env parsing that follows. 2. Outages masqueraded as bad keys. _key_accepted caught a broad Exception and returned False, so a 5xx or timeout while checking a cached key looked like a rejection: it discarded a good key and minted extra ones (local) or reported 'no saved key' (remote). Only treat HTTP 401/403 as a rejection; let other errors propagate so a real outage surfaces. 3. Codex preflight could leave the auto-started server up. _require_gguf_for_codex runs after _connect may have auto-started Studio but before _run installs its teardown finally, so a preflight rejection (e.g. a transformers-backend model) left the server holding the port/GPU until the atexit backstop. Tear it down explicitly at the point of failure. Tests: a 5xx on a saved key surfaces without minting; a non-GGUF codex preflight tears down the auto-served server. * start: fix IPv6/portless studio URLs, Pi config-dir isolation, and Pi install recipe Four review findings: 1. Pi ignored the session config when PI_CODING_AGENT_DIR was already set. Pi's getAgentDir() reads process.env.PI_CODING_AGENT_DIR before falling back to $HOME/.pi/agent, so a value inherited from the user's shell sent Pi to their real config and skipped our provider/key (the HOME relocation alone was not enough). Pin PI_CODING_AGENT_DIR at the session's .pi/agent dir; it is path-valued so the WSL bridge translates it automatically. 2. Pi install hint dropped Pi's documented --ignore-scripts. Pi's README installs with 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent' and notes it needs no install scripts, so accepting the prompt now follows that safe recipe. 3. Auto-start ignored a portless UNSLOTH_STUDIO_URL. unsloth run binds to 'parsed.port or 8888', so http://127.0.0.1 launched the child on 8888 but the health poll (and the returned base) still used port 80, stalling until the startup timeout. Normalize the base to host:8888 (IPv6-safe) before starting and polling. 4. API-panel command mistook IPv6 loopback for the bare default. The bare 'unsloth start' only probes 127.0.0.1:8888 on the IPv4 stack, so http://[::1]:8888 must carry an explicit UNSLOTH_STUDIO_URL. Drop ::1 from the bare-default host set while keeping it a loopback host (URL emitted, no key needed). Tests: PI_CODING_AGENT_DIR is set to the session dir; _effective_base normalizes portless/IPv6 bases; a portless UNSLOTH_STUDIO_URL auto-serves on :8888. * start: apply fresh-review findings across CLI, CI, and the API-panel command From a fresh multi-reviewer pass over the merged head plus the latest Codex bot review: 1. Load knobs now always consult the server. _resolve_model matched on model id alone, so --gguf-variant / --context-length / --no-load-in-4bit / --tensor-parallel were silently ignored whenever the id was already loaded (asking for UD-Q4_K_XL kept a Q8_0 serving). With any explicit knob the CLI defers to /api/inference/load, whose already-loaded dedup answers without reloading when variant and settings match, so a second session running the same command still attaches without evicting the first. 2. OpenCode --yolo and the session model pin now ride in OPENCODE_CONFIG_CONTENT. A project's own opencode.json outranks OPENCODE_CONFIG, so a repo config could silently override the session model and the --yolo permission block; OPENCODE_CONFIG_CONTENT outranks project config. The API key stays in the private file, never in printed env. 3. The --no-launch recipe's last line is a self-contained one-liner (inline VAR=value assignments before the command, conflicting vars blanked). People copy just the last line, and a bare codex/claude there ran against the user's real ~/.codex or Anthropic credentials with zero isolation, e.g. inheriting a pre-existing damaged ~/.codex state DB and blaming the recipe. The CI drive script scrubs the key from the one 'invoking:' echo this adds. 4. The auto-serve log is 0600 and the parent handle is closed. It sat world-readable in the shared tempdir under a predictable name while carrying the minted sk-unsloth- key from the unsloth run banner. 5. _key_accepted fails with a clean message on outages. Non-auth errors (5xx, network, timeout) surfaced as a raw traceback; 401/403 still mean a rejected key. 6. _effective_base strips URL paths, and https loopback targets never auto-serve. http://127.0.0.1:8888/studio polled /studio/api/health (404) and https://127.0.0.1 polled the wrong scheme, both spinning until the 15-minute startup timeout. 7. API-panel command: only literal 127.0.0.1:8888 earns the bare command. localhost can resolve to ::1, which the bare CLI never probes, so it keeps UNSLOTH_STUDIO_URL. 8. CI artifact sweep covers redacted-configs/ and agent-workdir/, not just logs/. Tests: 125 CLI tests pass (new coverage for each fix), 156 backend tests pass, ruff clean. Adds an unsloth connect alias regression test. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * start: hand Pi a clean screen at launch Pi paints inline from wherever the cursor sits: its first render assumes a clean screen instead of clearing or entering the alternate screen itself (current Pi never emits a clear at startup). Launched under unsloth start, that left the session starting mid-scroll beneath the connection output. Clear the screen (click.clear, cross-platform, no-op without a TTY) right before the Studio banner so Pi opens exactly one line down on a clean viewport. Launch path only: --no-launch recipes and piped output are never wiped, and alternate-screen agents are left alone. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * start: auto-override hermes' 64K context floor for small model windows Hermes refuses to initialize when the served model's context window is under 64,000 tokens, and a second copy of the same check rejects the compression model mid-session. write_hermes_config previously pinned the real window, so any small local model (e.g. 40,960) failed at startup with manual config.yaml instructions. For windows below the floor the recipe now claims 65,536 in model.context_length, scales compression.threshold so compaction still fires at 90% of the real window, and sets auxiliary.compression.context_length to cover the mid-session check. Windows at or above the floor keep the exact previous behavior. * ci: install pi with --ignore-scripts, matching the start.py hint The pi cell predates the pi recipe in start.py and still installed the package with lifecycle scripts enabled, so CI stopped exercising the exact command users are prompted to run. npm_retry now passes extra flags through, the pi branch mirrors the install hint verbatim, and the stale no-recipe comment is refreshed. * ci: fail loudly when a relocation var is missing from connect output The empty-string guards ran after appending /config.toml or /config.yaml, so they could never fire: crosscheck_contract silently skipped its contract checks and patch_hermes_tools died on the root path with a bare traceback. Check the raw variable first and guide_fail with the real cause. * staging: 6613 round 6 (https elision, no-launch home reuse, auto-start key fallback) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: shimmyshimmer <107991372+shimmyshimmer@users.noreply.github.com> Co-authored-by: Daniel Han <danielhanchen@gmail.com> Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Co-authored-by: Wasim Yousef Said <wasimysdev@gmail.com> |
||
|
|
264f1a04f8
|
Add Local Agent Guides CI (#6547)
Boot `unsloth run --disable-tools` against a small GGUF and drive each supported coding agent (claude, codex, hermes, openclaw, opencode, pi) through its documented `unsloth connect <agent> --no-launch` recipe, so the connect flow in unsloth_cli/commands/connect.py stays exercised end to end and regressions surface as a failing check. Per-agent matrix, three jobs: - connection: assert a non-empty, error-free reply to a trivial prompt - file-edit: a two-turn create-and-run hello.py test (dispatch/schedule only, skipped on pull_request) - prompt-cache: verify llama.cpp prefix-cache reuse across requests The GitHub-hosted runners are CPU-only, so each request is trimmed to the smallest prompt that still drives the recipe: claude with --tools to drop unused tool schemas (--allowedTools only gates permission, it does not shrink the prompt), hermes with an empty platform_toolsets.cli, and openclaw with a minimal agent definition. hermes and openclaw run a multi-turn tool loop in file-edit that a CPU runner cannot finish in time, so those two cells are best-effort; their endpoint wiring is still hard-gated by the connection job. A preflight step HTTP-checks each agent's API dialect before install so a server-side contract regression is reported separately from agent or guide drift. |
||
|
|
e83d4ae072
|
Windows installer: fix DiskPart UAC mid-install, drive-root cache, and spurious unsloth.exe rename warning (#6296)
* Windows installer: fix DiskPart UAC, drive-root cache, spurious rename warning, CPU-base messaging
amd-smi gate (DiskPart UAC mid-install): the AMD torch wheel ships hipInfo.exe
inside the venv, and the bitsandbytes fix prepends that venv Scripts dir to PATH.
shutil.which("hipinfo") then found it and flipped _amd_smi_allowed() to True, so
the post-install AMD probe fell through to `amd-smi list` (the venv hipInfo failed
to report gcnArchName, which is why the arch came from the GPU-name table) and
amd-smi elevated, popping the DiskPart UAC. Fix: a hipinfo resolved inside the
active venv (sys.prefix) is the torch-wheel binary, not a HIP SDK, and must not
open the gate. Mirrored in install_python_stack.py, install_llama_prebuilt.py, and
backend utils/hardware/amd.py (the runtime VRAM poller had the same latent prompt).
TORCHINDUCTOR_CACHE_DIR: move from C:\tc to <StudioHome>\TORCHINDUCTOR_CACHE_DIR so
the inductor/Triton cache lives under the user's Studio home, not the system drive
root. Long paths are already enabled above so deep inductor paths still fit.
unsloth.exe rename: skip the rename (and its "pip may fail with WinError 32"
warning) when SKIP_STUDIO_BASE=1. In the install.ps1 flow base packages are not
reinstalled, so unsloth.exe is never rewritten; the self-rename only failed because
setup runs via unsloth.exe (the running launcher holds its own file). The
'studio update' flow still attempts it.
CPU PyTorch messaging: clarify that the CPU base is temporary and setup replaces it
with GPU ROCm wheels, and print an explicit "GPU ROCm PyTorch installed" line after
the AMD wheels land, so the log makes clear the final install is GPU-accelerated.
Adds two regression tests covering the venv-internal vs external hipInfo gate.
Verified end-to-end on a Strix Halo box (Radeon 8060S / gfx1151): install.ps1
--local from this branch completed exit 0 with no DiskPart prompt, no rename
warning, the cache under the Studio home, and "GPU ROCm PyTorch installed
(gfx1151)"; Studio then booted and detected "ROCm (HIP 7.13.99004) -- AMD Radeon
8060S Graphics".
* Windows installer: drop the unreliable unsloth.exe rename and its WinError 32 warning
setup.ps1 used to rename the running unsloth.exe out of the way before the
base-package upgrade so pip could replace it. That rename never actually
worked: setup runs *via* unsloth.exe, so renaming our own running
uv-trampoline launcher failed with a sharing violation (WinError 32) and only
printed a scary 'could not rename unsloth.exe; pip may fail with WinError 32'
warning on every Windows install and update.
It also was not needed. pip tolerates a running/locked console-script .exe: it
moves the old one aside and writes the new one. The base upgrade routes through
pip on Windows, so the upgrade succeeds (or, in the install.ps1 flow with
SKIP_STUDIO_BASE=1, the base is not touched at all) and unsloth.exe is left
intact either way.
Removing the rename block and its failed-install restore block removes the
false warning for all Windows devices in both the install and update flows.
* Windows installer: gate venv-internal hipInfo.exe in PowerShell amd-smi probe; harden venv path checks
Follow-up to PR #6296.
- install.ps1 and setup.ps1: ignore the AMD torch wheel hipInfo.exe that lives
inside the Studio venv when probing for a HIP SDK, so amd-smi no longer reopens
the DiskPart UAC during install/update. Mirrors _path_inside_venv in the Python
installers, which already do this.
- amd.py, install_llama_prebuilt.py, install_python_stack.py: normcase the venv
containment check (Windows paths are case-insensitive) and run the
HIP_PATH/ROCM_PATH candidate through it too.
- setup.ps1: fall back to a short TORCHINDUCTOR cache dir when long paths are
unavailable, and create the dir wildcard-safely.
- tests: isolate sys.prefix in the gate helper, add HIP_PATH/ROCM_PATH cases, and
assert the PowerShell venv exclusion.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Windows installer: install ROCm PyTorch directly for a known AMD arch
When the GPU arch is known (name-inferred from the GPU-name table) but ROCm
could not be probe-verified (no HIP SDK, no amd-smi), the bootstrap installed
a CPU PyTorch base that setup.ps1 then force-reinstalled as ROCm. The
repo.amd.com wheels bundle their own runtime (no HIP SDK required), which
setup.ps1 already relies on, so the CPU base was a pure wasted download/install.
- Gate the ROCm index on a known arch, not only on probe-verified ROCm, so a
mapped arch installs ROCm torch directly. Unmapped arches and no-GPU hosts
still get CPU (unchanged).
- Fall back to a CPU base if the ROCm-index install fails, so a transient
repo.amd.com outage does not abort the install (setup.ps1 retries ROCm).
- Correct the stale comment that claimed ROCm wheels need a confirmed HIP SDK.
- Add a regression test for the arch-based gate.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Windows installer: correct the unsloth.exe rename-removal comment
The comment claimed the base upgrade 'routes through pip on Windows' and that
pip 'moves the old unsloth.exe aside, then writes the new one'. That is not what
the code does. install_python_stack tries uv first; on a locked launcher uv
aborts and falls back to pip, but the pip fallback strips --upgrade-package and
base.txt lists only bare unsloth/unsloth-zoo, so pip finds them already
satisfied and no-ops. The running unsloth.exe is left intact at its current
version either way. Reword the comment to describe the real uv-first /
pip-fallback-no-op behavior. No functional change.
* Windows installer: close two gaps in the venv-internal hipinfo exclusion
Review follow-up. The amd-smi/DiskPart gate could still reopen in two cases:
- setup.ps1 ran the HIP probe long before $VenvDir is assigned, so without
VIRTUAL_ENV (the `unsloth studio update` path) $venvRoots was empty and the
venv-internal hipInfo.exe was not recognized. Seed the venv root from
UNSLOTH_SETUP_PYTHON and the default Studio home too (both installers).
- The HIP_PATH/ROCM_PATH candidate was accepted without the venv filter, so an
env var pointing into the venv (AMD wheel) still set $HipSdkInstalled. Run
Test-HipinfoIsVenvInternal on the candidate as well (both installers).
Extend the PS gate test to assert both. Both .ps1 parse clean; install tests
pass (the venv-internal / HIP probe coverage at 359 passed).
* Windows installer: correct the CPU-base message for arches with no ROCm wheels
After gating the ROCm index on a known arch, a mapped arch sets $ROCmIndexUrl
and installs ROCm directly, so it no longer reaches the "temporary CPU base"
branch. That branch is now reached only by a name-inferred arch with no ROCm
wheels (e.g. RDNA2 gfx103X), where setup.ps1 does NOT install ROCm. The old
text ("setup replaces it with GPU ROCm wheels ... the final install IS
GPU-accelerated") was therefore always wrong there. Say plainly that PyTorch
stays on CPU for this GPU.
* Windows installer: seed the venv-internal hipInfo check from a custom Studio home
Test-HipinfoIsVenvInternal seeded the venv root from VIRTUAL_ENV, VenvDir, the
setup python, and the default %USERPROFILE% path only. A standalone
`unsloth studio update` with a custom UNSLOTH_STUDIO_HOME (or STUDIO_HOME alias)
and none of those set would not recognize the venv hipInfo on PATH, reopening the
amd-smi/DiskPart gate. Seed the custom home too, in both installers, and assert
it in the gate test.
* Studio installer: resolve venv aliases and expand ~ in the hipInfo venv filter
Two review points on the amd-smi/DiskPart UAC gate:
1. _path_inside_venv compared os.path.abspath of sys.prefix and the hipInfo
path, which does not resolve symlinks, junctions, or 8.3 short names. A venv
reached through an aliased path then fails the check, so its bundled
hipInfo.exe is mistaken for an external HIP SDK and amd-smi runs (the
DiskPart prompt this fix exists to suppress). Switch to os.path.realpath in
all three copies (amd.py, install_llama_prebuilt.py, install_python_stack.py).
2. setup.ps1's early venv-internal hipInfo probe seeded the venv root from a
custom Studio home (UNSLOTH_STUDIO_HOME / STUDIO_HOME) without expanding a
leading ~, while the canonical resolver does. With a tilde form,
[IO.Path]::GetFullPath kept the literal ~ relative to cwd, so the custom-home
hipInfo escaped the filter and reopened the gate. Expand ~ in the probe the
same way as the resolver.
tests/studio/install/test_pr5940_followups.py: 30 passed (adds a symlink
realpath case and a setup.ps1 tilde-expansion guard).
* Studio installer: mirror the hipInfo venv filter and ROCm wheel pins into install.ps1
Follow-up review on the same install.ps1 paths:
1. install.ps1's venv-internal hipInfo probe (Test-HipinfoIsVenvInternal)
seeded the venv root from a custom Studio home without expanding a leading
~, unlike the canonical resolver and setup.ps1. A tilde form left
[IO.Path]::GetFullPath with the literal ~ (relative to cwd), so the
custom-home hipInfo escaped the filter and reopened the amd-smi/DiskPart
gate. Expand ~ in the probe, matching the setup.ps1 fix.
2. The AMD ROCm path installed torchvision/torchaudio bare while pinning torch
to below 2.12. AMD's per-arch index publishes the companions independently
and may ship torchvision 0.27 (for torch 2.12) before removing 0.26, so a
bare resolve can pick an ABI-incompatible set and fall back to CPU. Add
torchvision/torchaudio floor maps and pass the pinned specs, mirroring
setup.ps1 and install_python_stack.py.
3. The ROCm-to-CPU fallback torch install used Invoke-InstallCommand (no
retry), the only torch step in the file without it. Switch to
Invoke-InstallCommandRetry so the recovery path survives a transient index
failure.
tests/studio/install/test_pr5940_followups.py: 33 passed (parametrized tilde
check over both installers, a torch/companion floor-map parity test, and a
CPU-fallback retry guard).
* Studio installer: scan all PATH hipinfo so the venv copy can't shadow a real HIP SDK
The amd-smi HIP-SDK probe used shutil.which("hipinfo") / Get-Command hipinfo,
which return only the first hit on PATH. The AMD torch wheel ships hipInfo.exe
inside the venv and the bnb fix (plus the Studio backend) prepend the venv
Scripts dir to PATH, so that venv-internal copy lands first. When a real HIP SDK
hipinfo sits later on PATH with HIP_PATH/ROCM_PATH unset, the first-hit probe
stopped at the venv copy, treated it as "not a HIP SDK", and closed the amd-smi
gate -- AMD users in that PATH-only SDK setup lost amd-smi telemetry and could
fall back to CPU. Scan every PATH entry and keep the first hipinfo that is not
venv-internal; only the venv copy is ignored, so the UAC/DiskPart suppression is
unchanged.
Applied to all three Python copies (install_llama_prebuilt.py,
install_python_stack.py, backend/utils/hardware/amd.py) via a new
_external_hipinfo_on_path helper, and both PowerShell callers (install.ps1,
setup.ps1) now use Get-Command hipinfo -All filtered by Test-HipinfoIsVenvInternal.
tests/studio/install/test_pr5940_followups.py: 36 passed (real-PATH scan tests, a
shadow-regression test for the exact venv-first ordering, and a parity check that
every Python copy uses the scanning helper).
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio uninstallers: fix leftovers (false "removed", shared icon, llama lock)
Auditing a dual native+WSL uninstall on a real device surfaced three leftovers:
1. uninstall.ps1 removed the data dir (which holds unsloth.ico) before the
shortcuts that reference that icon, so Explorer's icon cache briefly held it
open. Remove-Item -Recurse reported success yet left the locked file, and the
dir was never re-attempted, so it orphaned with a false "removed" log.
_RemovePath now verifies the path is actually gone (retrying transient locks)
and reports honestly, and the data dir is re-swept after the shortcuts go.
2. install.sh writes a shared unsloth.ico to %LOCALAPPDATA%\Unsloth Studio for
the WSL shortcut, but uninstall.sh never removed it, orphaning the icon (and
dir) after a WSL uninstall. uninstall.sh now drops that icon and the dir when
empty, in both the powershell.exe and drvfs-fallback paths.
3. ~/.unsloth/.llama.cpp.install.lock was never removed, so the rmdir of
~/.unsloth failed and the dir lingered. Both uninstallers now remove the lock.
Verified by running both uninstallers on a real dual install: device fully clean
(no install dirs, shortcuts, PATH/registry entries, shared icon, or lock left).
* install.sh: auto-route Strix Halo WSL to an existing Ubuntu 24.04
ROCm-on-WSL is the GPU runtime for Strix Halo and only targets Ubuntu
24.04. When the installer runs in a newer default distro (e.g. 26.04) it
cannot enable the GPU and silently falls back to CPU. If a 24.04 distro
already exists, re-run the install there and stop in the current one so the
GPU path is taken without the user having to know about the distro
requirement.
Runs before venv creation so the wrong distro is left untouched, guards
against re-route loops via UNSLOTH_WSL_REROUTED, leaves a working ROCm
distro alone (librocdxg present), and skips the GGUF-only / opt-out /
non-Strix cases. When no 24.04 distro exists we keep today's behaviour:
continue to CPU and print the `wsl --install Ubuntu-24.04` guidance, never
auto-downloading a distro.
Adds tests/sh/test_strixhalo_wsl_reroute.sh (hermetic: extracts the
function, rewrites its paths to fixtures, mocks wsl.exe) covering the full
decision matrix, wired into tests/run_all.sh.
* uninstall.ps1: keep shared unsloth.ico for a surviving WSL shortcut
A dual native+WSL install shares %LOCALAPPDATA%\Unsloth Studio\unsloth.ico:
install.sh points the WSL shortcut's icon there while the native install owns the
dir. The native uninstaller removed the whole dir unconditionally, so uninstalling
native while keeping WSL left the WSL shortcut with a blank icon. The old code only
avoided this when Explorer happened to hold the icon open, which is unreliable; on a
real dual install the dir was deleted and the WSL shortcut went blank.
_RemoveDataDirKeepingWslIcon now scans the Start Menu + Desktop for a surviving
"Unsloth Studio (WSL ...).lnk" and, if found, removes everything in the data dir
except unsloth.ico (keeping the dir) instead of deleting it; with no WSL shortcut it
removes the dir as before. uninstall.sh still drops the icon and the empty dir when
WSL itself is uninstalled, so every uninstall order ends clean.
Adds tests/studio/test_uninstall_dual_install_icon.ps1 (AST-extracts the helper and
runs it against a temp dir with controlled shortcut dirs) covering the dual,
native-only, empty, and missing-dir cases, wired into the windows-inference smoke
workflow. Verified on a real dual install: native uninstall now keeps unsloth.ico
and the WSL shortcut's icon stays intact.
* installer: condense AMD/ROCm code comments (no behavior change)
Tighten the comments added for the Strix Halo native+WSL installer work so
they are shorter and clearer without losing intent: the venv-internal hipInfo
amd-smi gate, the ROCm torch/companion floor maps, the WSL 24.04 reroute, and
the dual-install uninstall icon handling. Comment-only; code paths unchanged.
107 insertions, 166 deletions across 11 files.
* install.sh: run the Strix Halo WSL reroute before any STUDIO_HOME write
The reroute fired after mkdir -p "$STUDIO_HOME" and the legacy-venv migration,
so rerouting 26.04 -> 24.04 left an empty ~/.unsloth/studio stub in the origin
distro (and ran venv migration in the distro about to be abandoned). Move the
reroute ahead of the venv section so the origin distro is left untouched, matching
the function's own comment. Behavior is identical on every non-reroute path.
* installer: fix ROCm CPU-fallback, hipinfo gate edge cases, uninstall icon, WSL 22.04
- install.ps1: clear $ROCmIndexUrl/$ROCmTorchFloor after the CPU fallback so the
flavor-repair block does not retry the failed ROCm index and abort the install;
pin the ROCm companion specs ($visionSpec/$audioSpec) in the repair path too.
- install.ps1 + setup.ps1: skip a bare drive root in Test-HipinfoIsVenvInternal so a
non-venv UNSLOTH_SETUP_PYTHON does not match the whole drive; iterate
HIP_PATH/HIP_PATH_57/ROCM_PATH and take the first non-venv hipinfo.
- amd.py, install_llama_prebuilt.py, install_python_stack.py: strip surrounding
quotes from PATH entries before probing for hipinfo.
- install.sh: pipefail the WSL reroute curl|sh; do not reroute supported Ubuntu 22.04.
- uninstall.sh: keep the shared unsloth.ico while any Unsloth shortcut (native or
another WSL distro) still references it, in both the powershell and drvfs paths.
- tests: regression coverage for all of the above.
* installer: forward reroute options, guard ROCm bootstrap, harden hipinfo gate
- install.sh: forward the caller's --package/--python/--verbose/--tauri and a custom
UNSLOTH_STUDIO_HOME into the WSL reroute (was a bare default install); bail on
--local; run the reroute BEFORE dependency/uv install so the origin distro is left
untouched; set UNSLOTH_SKIP_ROCM_WSL_SETUP after a failed reroute so the later
ROCm-on-WSL bootstrap does not install into the unsupported origin distro.
- install.ps1 + setup.ps1: Get-Command hipinfo -CommandType Application so only real
executables match (not an alias/function named hipinfo).
- uninstall.ps1: guard $env:APPDATA when building the default shortcut search dirs.
- tests: cover option forwarding, --local bail, the bootstrap guard, and the gate change.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* installer: guard origin ROCm bootstrap on every CPU-only fallback; harden ~ expansion
WSL reroute: the no-wsl.exe, no-24.04-target and --local fallbacks all tell the
user the install continues CPU-only, but only the failed-reroute branch set
UNSLOTH_SKIP_ROCM_WSL_SETUP=1. The later _maybe_bootstrap_rocm_wsl gate keys off
that flag, so the other three branches could still install ROCm into the
unsupported origin distro (e.g. 26.04). Set the skip guard on all of them.
Forward UNSLOTH_ROCM_WSL_AUTO into the reroute so a Tauri/consented GPU bootstrap
carries through to the rerouted 24.04 child instead of dropping to the prompt path.
install.ps1/setup.ps1: guard the venv-probe ~ expansion on a non-empty
$env:USERPROFILE so Join-Path does not throw on a profile-less service account.
Tests: add no-wsl.exe and UNSLOTH_ROCM_WSL_AUTO reroute cases, the USERPROFILE
guard assertion, and route shell-test fixtures through a single trap-cleaned root.
* installer: pin + soften Windows ROCm Python repair, reroute to 22.04, harden gates
install_python_stack.py: the Windows AMD ROCm repair in _ensure_rocm_torch()
installed bare torch/torchvision/torchaudio via the fatal pip_install -- the same
asymmetry already fixed on the PowerShell side. A transient repo.amd.com failure
could abort the whole install even after install.ps1/setup.ps1 fell back to CPU.
Pin companions per-arch (gfx120X/Strix -> the rocm7.2 trio, mirroring the PS floor
maps) and make the retry nonfatal: keep the existing build and let the user re-run
update to retry ROCm, so the chain install.ps1 -> setup.ps1 -> stack stays CPU-safe.
install.sh: reroute now targets an installed Ubuntu 24.04 OR 22.04 (24.04 preferred);
both are AMD-supported for ROCm-on-WSL, matching the leave-alone set, so a box with
only 22.04 reaches the GPU instead of staying CPU-only.
install.ps1/setup.ps1: a bare ~ for UNSLOTH_STUDIO_HOME left an empty Join-Path child
(PS 5.1 throws); fall back to USERPROFILE directly and only join a real remainder.
_path_inside_venv (amd.py + both installers): guard a root-dir sys.prefix so commonpath
can't classify every path on the drive as venv-internal (defensive; venv never at root).
uninstall.sh: guard an empty LOCALAPPDATA in the PS-interop icon cleanup (mirror APPDATA).
Tests: add 22.04-target reroute cases, Windows ROCm pin+nonfatal coverage (text +
behavioral), root-dir guard coverage, and bare-~/LOCALAPPDATA guard assertions.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* install.sh: match WSL reroute target by exact distro name, not substring
The 24.04/22.04 reroute target was chosen with grep -F (substring), so a custom
distro such as 'Ubuntu-24.04-test' (with no exact Ubuntu-24.04) was picked as the
target; the later 'wsl -d Ubuntu-24.04' then fails and the Strix Halo install stays
CPU-only. Match whole lines (grep -ixF) and reuse the matched name so only a real
Ubuntu-24.04/22.04 is targeted. Adds substring-rejection + exact-vs-custom tests.
* install.sh: keep the WSL reroute target to Ubuntu 24.04 (helper-supported only)
The ROCm-on-WSL bootstrap (scripts/install_rocm_wsl_strixhalo.sh) dies on any
VERSION_ID other than 24.04 and pins the noble repo, so treating 22.04 as
GPU-supported let the parent report a successful reroute while the child fell
back to CPU. Drop 22.04 from the supported set and the reroute target list;
24.04 stays the sole target (keeping the exact whole-line distro match). An
already-working ROCm on any other version is still left alone by the librocdxg
check above.
tests: reroute 22.04 cases updated to the 24.04-only behavior; make the
"no wsl.exe" case hermetic so a real host wsl.exe can't leak in on dev boxes;
stop the tauri exit-order check from mis-flagging the reroute helper's
[ "$TAURI_MODE" = true ] && ... --tauri one-liner.
* installer: tighten comment wording across the Strix Halo install/uninstall paths
Condense the verbose multi-line comment blocks (amd-smi hipinfo gate, ROCm
torch install + CPU fallback, WSL reroute, uninstall icon-keep) into fewer,
clearer lines. Comments and a few docstrings only; no code, logic, or
behavior change. Verified with bash -n, the PowerShell parser, and ast.parse,
and the installer test suite still passes.
* add AGPL-3.0 SPDX headers to the .sh/.ps1 scripts missing them
Every shell and PowerShell script under the Studio/installer surface now
carries the standard SPDX-License-Identifier: AGPL-3.0-only + copyright
header (after the shebang where present): the installer (install.sh,
install.ps1), build.sh, the .github and src-tauri scripts, the installer
test suite, and the moe kernel test. Header-only, line endings preserved;
bash -n, the PowerShell parser, and the installer tests all pass.
* installer: drop the duplicate AGPL header from install.sh and install.ps1
Both already carry an SPDX-License-Identifier: AGPL-3.0-only header below
their usage comment block; the prior header pass added a second one at the
top because it only scanned the first few lines. Remove the duplicate so each
file keeps a single original header.
* installer: force-reinstall CPU fallback torch; propagate Tauri NEED_SUDO from reroute
install.ps1/setup.ps1: when the AMD ROCm wheel install fails and we fall back to a
CPU base, force-reinstall the torch/vision/audio triplet. A failed ROCm install can
leave an unpinned ROCm torch (e.g. 2.10.0+rocm on gfx110X/gfx90a) that still
satisfies the CPU torch>=2.4,<2.11.0 range, so without --force-reinstall uv keeps the
ROCm build and only swaps the companions -- a mismatched venv the flavor-repair block
won't fix. setup.ps1 scopes the forced reinstall to the ROCm-fallback path
() so the genuine CPU-only install stays fast.
install.sh: the Strix Halo WSL reroute treated every nonzero child exit as a reroute
failure and fell back to CPU. In --tauri mode the child uses exit 2 ([TAURI:NEED_SUDO])
to ask the desktop app to elevate for the target distro; capture the child's exit code
and propagate exit 2 in Tauri mode (the child already printed the NEED_SUDO line)
instead of masking it. CLI mode still falls back to CPU on a generic failure.
Tests: reroute Tauri exit-2 propagation (and non-Tauri CPU-fallback) cases;
run_func now preserves the child exit code; force-reinstall assertions for both
PowerShell installers.
Note: codex's _rr_q apostrophe finding is a false positive -- the helper already
emits POSIX-correct 'O'\''Brien' and round-trips under both sh and bash.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* setup.ps1: fix $cpuForce array collapse in the ROCm->CPU torch fallback
An if-expression assignment ($cpuForce = if ($ROCmCpuFallback) { @("--force-reinstall") })
collapses the single-element array to a scalar string, so @cpuForce splatting enumerated
it character-by-character into broken single-letter args (- - f o r c e ...), which made
uv/pip reject the install and aborted the whole Studio setup on the AMD ROCm->CPU fallback
path. Build $cpuForce as a real array assigned outside the if-expression so the splat passes
a single --force-reinstall arg. Genuine CPU-only installs stay fast (empty array, no flag).
Test now asserts the array-build form and rejects the if-expression form.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* uninstall: remove the isolated Node.js runtime (~/.unsloth/node)
The isolated Node.js runtime (install_node_prebuilt.py, added with the managed-Node
change) installs to ~/.unsloth/node in default mode -- a sibling of studio, so deleting
<studio> leaves it behind (~200MB orphaned after uninstall). Both uninstallers already
remove the other default-mode siblings (llama.cpp/.cache/.staging); add node alongside
them. uninstall.ps1 also adds it to the handle-lock sweep so a held node.exe can't block
the delete. Env/custom mode nests node under the custom root, removed with that root.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
---------
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
|
||
|
|
366937de44
|
studio: pick a macOS llama.cpp prebuilt that loads on the host OS (#5883)
Make macOS llama.cpp prebuilt selection host-OS-version aware: skip a prebuilt whose minimum-OS exceeds the host and walk back to the newest release that loads (macOS 26 keeps latest; 14/15 land on a compatible older release). Source-build fallback pins CMAKE_OSX_DEPLOYMENT_TARGET=13.3. CI: binary-load assertion plus a macos-14/15/26 install matrix. No change to Linux/Windows or CUDA selection. |
||
|
|
54a86c3514
|
ci: route every hf download through xet-tuned stall-retry wrapper (#5476)
Some checks are pending
Security audit / npm scan-packages (Studio frontend tarballs) (push) Waiting to run
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Waiting to run
Security audit / pytest tests/security (push) Waiting to run
Security audit / npm provenance + new install-script diff (push) Waiting to run
Studio API CI / Studio API & Auth Tests (push) Waiting to run
Backend CI / (Python 3.10) (push) Waiting to run
Backend CI / (Python 3.11) (push) Waiting to run
Backend CI / (Python 3.12) (push) Waiting to run
Backend CI / (Python 3.13) (push) Waiting to run
Backend CI / Repo tests (CPU) (push) Waiting to run
Frontend CI / Frontend build + bundle sanity (push) Waiting to run
Studio GGUF CI / OpenAI, Anthropic API tests (push) Waiting to run
Studio GGUF CI / Tool calling Tests (push) Waiting to run
Studio GGUF CI / JSON, images (push) Waiting to run
Mac Studio API CI / Studio API & Auth Tests (push) Waiting to run
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Waiting to run
Mac Studio GGUF CI / Tool calling Tests (push) Waiting to run
Mac Studio GGUF CI / JSON, images (push) Waiting to run
Mac Studio UI CI / Chat UI Tests (push) Waiting to run
Mac Studio Update CI / Studio Updating Tests (push) Waiting to run
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Waiting to run
Studio UI CI / Chat UI Tests (push) Waiting to run
Studio Update CI / Studio Updating Tests (push) Waiting to run
Windows Studio API CI / Studio API & Auth Tests (push) Waiting to run
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Waiting to run
Windows Studio GGUF CI / Tool calling Tests (push) Waiting to run
Windows Studio GGUF CI / JSON, images (push) Waiting to run
Windows Studio UI CI / Chat UI Tests (push) Waiting to run
Windows Studio Update CI / Studio Updating Tests (push) Waiting to run
Wheel CI / Wheel build + content sanity + import smoke (push) Waiting to run
Root cause of the Mac json-images 30 min timeout (run 25950714888 / PR #5430): huggingface_hub>=1.15 deprecated `hf_transfer` and routes every transfer through `hf-xet`. The CI step's unpinned `pip install --upgrade huggingface_hub hf_transfer` jumped to 1.15.0 + hf-xet 1.5.0, the 940 MB mmproj finished in ~21s, then the 3 GB gemma-4 GGUF made it to ~46% and went completely silent for the remaining 29 minutes -- no progress bytes, no error, no exit -- until the job timeout fired. This wraps every CI `hf download` in a new `.github/scripts/hf-download-with-retry.sh`: * Drops the no-op `HF_HUB_ENABLE_HF_TRANSFER=1` prefix and the `hf_transfer` install (both are deprecated on 1.15+ and only emit a FutureWarning now). * Exports the hf-xet high-performance knobs Daniel asked for: HF_XET_HIGH_PERFORMANCE=1 HF_XET_CHUNK_CACHE_SIZE_BYTES=0 HF_XET_NUM_CONCURRENT_RANGE_GETS=64 HF_XET_RECONSTRUCT_WRITE_SEQUENTIALLY=0 HF_XET_CLIENT_READ_TIMEOUT=500 * Watchdogs each attempt: if `hf download` has not exited after HF_DOWNLOAD_STALL_SECONDS (default 180s = 3 min), SIGTERM, sleep 2, SIGKILL, then loop. Retries are unbounded; the enclosing job's `timeout-minutes` is the real cap. * Optional 3rd positional `LOCAL_DIR` -- omitted lets `hf` use the default HF_HUB_CACHE, which is what the HF_HOME-priming jobs need. 19 call sites migrated across mlx-ci.yml + 9 studio-*-smoke.yml workflows. The inline `python -c "from huggingface_hub import hf_hub_download; ..."` block in mlx-ci.yml is also routed through the wrapper so every hf transfer in CI gets the same treatment. Also reverts the json-images timeout 45 -> 30 from #5475: the bump was masking this hang, not fixing it. |