From afad21dda56540b63d6dfe50faaed9abe2180126 Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 31 Oct 2026 10:00:11 -0700 Subject: [PATCH 01/18] fix(media): verify read the library the way the servers write it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A verify run beside a running scan reported rows the scanner had just written as MISSING, and could fail outright as "database disk image is malformed". Both libraries live in WAL mode, and the verifier opened them with immutable=1, which tells sqlite the file never changes: it skips the write-ahead log, so every committed-but-uncheckpointed row is invisible. 134 MB of jellyfin.db-wal was riding past the verifier's eyes during the 2026-10-31 rescan. Open plain mode=ro first — sqlite then reads the WAL through its -shm — and keep immutable=1 only as the fallback for a database whose -shm cannot be read. The bats fixture leaves a journaled move uncheckpointed and proves the verifier still counts it landed. --- scripts/media-verify.py | 25 +++++++++++++++++++------ tests/media_verify.bats | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/scripts/media-verify.py b/scripts/media-verify.py index 23cca6d..87e2861 100755 --- a/scripts/media-verify.py +++ b/scripts/media-verify.py @@ -9,8 +9,13 @@ its destination, and NO row may still name its source — a source row is a scan that has not caught up, and it is the one signature of a library pointing at files that no longer exist. -Two doors, both read-only, both without a lock while the server runs -(`mode=ro&immutable=1`; there is no `sqlite3` binary on these hosts): +Two doors, both read-only, both without a lock while the server runs (there is +no `sqlite3` binary on these hosts). Open `mode=ro` PLAIN: both servers keep +their libraries in WAL mode, and `immutable=1` — which claims the file never +changes — makes sqlite skip the write-ahead log entirely, so every row the +server has committed but not yet checkpointed reads as MISSING, and a read +beside a running scanner can even fail as "database disk image is malformed". +`immutable=1` is only the fallback, for a database whose `-shm` cannot be read. jellyfin `BaseItems.Path`. The container binds the share at `/data`, so the database holds `/data//…` where the journal holds @@ -92,10 +97,18 @@ def read_moves(path): def server_paths(db, kind): """Every path the server holds, or a refusal that names what is missing.""" spec = KINDS[kind] - try: - con = sqlite3.connect(f"file:{db}?mode=ro&immutable=1", uri=True) - except sqlite3.Error as e: - die(f"cannot open {db}: {e}") + con = None + err = None + # Plain ro reads the WAL the server commits through; immutable is the last + # resort, because it cannot see a row the server has not checkpointed. + for opt in ("mode=ro", "mode=ro&immutable=1"): + try: + con = sqlite3.connect(f"file:{db}?{opt}", uri=True) + break + except sqlite3.Error as e: + err = e + if con is None: + die(f"cannot open {db}: {err}") with con: have = {r[1] for r in con.execute(f"pragma table_info({spec['table']})")} if not spec["columns"] <= have: diff --git a/tests/media_verify.bats b/tests/media_verify.bats index 1d9f8e1..a230dbf 100755 --- a/tests/media_verify.bats +++ b/tests/media_verify.bats @@ -210,3 +210,38 @@ setup() { [ "${lines[3]}" = "missing from the DB: 0" ] [ "${lines[4]}" = "not indexed (extension ignored): 1" ] } + +# wal_case — a Jellyfin database whose NEW row lives only in the +# write-ahead log: the server has committed the rename but not checkpointed it, +# which is the normal state of a library a scan is writing. The holder +# connection keeps the WAL from folding into the main file, and the verifier +# runs as a real subprocess so it faces the same files Plex and Jellyfin leave. +wal_case() { + python3 - "$JFDB" "$JOURNAL" "$SHARE" "$VERIFY" <<'PY' +import sqlite3, subprocess, sys +db, journal, share, verify = sys.argv[1:5] +w = sqlite3.connect(db) +w.execute("pragma journal_mode=wal") +w.execute("pragma wal_autocheckpoint=0") +w.execute("insert into BaseItems (Path, Type) values ('/data/Movies/Old.mkv', 'x')") +w.commit() +w.execute("pragma wal_checkpoint(truncate)") +holder = sqlite3.connect(db) # last-connection close would checkpoint +w.execute("update BaseItems set Path='/data/Movies/Old/Old.mkv'") +w.commit() +r = subprocess.run([sys.executable, verify, "--journal", journal, + "--db-kind", "jellyfin", "--db", db, + "--share", share, "--container", "/data"], + capture_output=True, text=True) +print(r.stdout, end="") +sys.exit(r.returncode) +PY +} + +@test "a row the server committed but has not checkpointed is proof, not a miss" { + move_row "$SHARE/Movies/Old.mkv" "$SHARE/Movies/Old/Old.mkv" + run wal_case + [ "$status" -eq 0 ] + [ "${lines[1]}" = "landed: 1" ] + [ "${lines[3]}" = "missing from the DB: 0" ] +} From 7ee831adee1affffe435a947e0126b98b7dca29e Mon Sep 17 00:00:00 2001 From: Frank Date: Sat, 31 Oct 2026 10:13:11 -0700 Subject: [PATCH 02/18] docs(media): what a verification verdict actually means, and what a rollback can undo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things the 2026-10-31 five-library relayout taught, written where the next operator reads them: - A verify run must open the servers' databases plain `mode=ro`. Both keep their libraries in WAL mode; `immutable=1` skips the write-ahead log, so a scanner's own committed rows read as missing and the read can fail as "database disk image is malformed". The census page claimed immutable — now it says what the code does. - "No row at the destination" splits into four classes, and only REAL (no row at dst AND the file still at src) is the relayout's own damage. Phantom rows naming a pre-move path are normal behaviour for both servers. And the owning server decides whether a pass can convict: Jellyfin holds no audiobook library, so that journal verifies against Plex or it proves nothing. - A chain of passes rolls back newest journal first, or the tool refuses; the refusals measured that day are the tool working, not failing. The music and audiobook arms moved to their own pages (progressive disclosure; the parent drops 175 lines and stays the owner of the shared gates, the executor, the journal law and the rollback). Stale per-run counts destroyed: episode counts, byte sizes, move tallies for a pass that already happened. --- .../Dankserver/Media Audiobook Relayout.md | 74 ++++++++ .../Dankserver/Media Music Relayout.md | 68 +++++++ .../Dankserver/Media Relayout.md | 175 +++++------------- serverAppConfigs-docs/index.md | 2 + 4 files changed, 190 insertions(+), 129 deletions(-) create mode 100644 serverAppConfigs-docs/Dankserver/Media Audiobook Relayout.md create mode 100644 serverAppConfigs-docs/Dankserver/Media Music Relayout.md diff --git a/serverAppConfigs-docs/Dankserver/Media Audiobook Relayout.md b/serverAppConfigs-docs/Dankserver/Media Audiobook Relayout.md new file mode 100644 index 0000000..4b0fdc7 --- /dev/null +++ b/serverAppConfigs-docs/Dankserver/Media Audiobook Relayout.md @@ -0,0 +1,74 @@ +--- +type: reference +title: Media Audiobook Relayout +description: "Re-filing /mnt/entropy/Audiobooks by Plex's chapter rows: narrator shelves, no renumbering, the refusals." +tags: [dankserver, media-server, plex, media-relayout] +timestamp: 2026-10-31T00:00:00Z +--- + +# Media Audiobook Relayout + +`/mnt/entropy/Audiobooks` is the one library here that [[Jellyfin]] does not read +at all — its music scanner has no audiobook concept — while [[Plex]] indexes all +of it as Artist → Album → Track: the album is the BOOK, that book's single artist +is the NARRATOR, both matched out of the files' own tags. Today one book is split +across `… [Part 1]`, `… [Part 2]` folders three or four deep inside a collection +pack, so a scan serves three quarters of a novel; `scripts/media-audiobooks.py` +re-files the tree so the property both servers' book scanners assume is true — + + Audiobooks// ()/ + +The shared gates, the executor, the journal law and the rollback live in +[[Media Relayout]]; this page is the audiobook arm only. + +```bash +# 1. The authority: Plex's chapter rows for this root (the DB opens read-only). +python3 scripts/media-census.py audiobooks --root /mnt/entropy/Audiobooks \ + --out /tmp/ab_rows.tsv +# 2. What is on disk (stat only). +python3 scripts/media-census.py enumerate --root /mnt/entropy/Audiobooks \ + --out /tmp/ab_enum.tsv +# 3. The plan. +python3 scripts/media-audiobooks.py --root /mnt/entropy/Audiobooks \ + --enum /tmp/ab_enum.tsv --rows /tmp/ab_rows.tsv --out /tmp/ab_plan.tsv \ + --exceptions /tmp/ab_exceptions.txt +# 4. Read the exceptions file FIRST. Then the same executor, journal and rollback +# as [[Media Relayout]] shows, on its own journal, and refresh the Plex section +# as in step 5 of the movie Procedure — from inside the container, never over +# the host port. +scripts/media-apply.py apply --plan /tmp/ab_plan.tsv --root /mnt/entropy \ + --journal /mnt/entropy/media-relayout-audiobooks.jsonl --apply +``` + +The group is the identity Plex asserts in its own database — **(narrator, book, +year)** — so one book arriving in four part folders becomes one folder, and a +1965 and a 2020 recording that share a title stay two books. + +Two laws are forced by what the rows actually hold: + ++- **A chapter is never renumbered.** Most books contain chapters whose index Plex + does not know, so a number written into a name would be invented — and it would + buy nothing, because both servers order playback by the embedded track tag, not + by the file name. A wrong number in a name is the number a human later trusts. + Fix order in the tags: a Plex scan re-reads tags and never renames a file. ++- **The shelf is the narrator, not the author.** Every book has exactly one artist + row and it is the narrator; the author survives only inside some album titles + (`Kryptos by Graham McNeill`) and in no column Plex stores, so deriving it would + mean parsing titles where a ripper wrote one and inventing it everywhere else. + The author stays visible in the folder name Plex itself shows. + +What it refuses, the music arm's list with one addition: a video container, audio +no chapter row names, and a loose file in a folder holding two books all park under +`_unsorted` with the path they have today — and so does a chapter whose row names +**no book or no narrator**: a placeholder (`[Unknown Album]`, `[Unknown Artist]`) +that unrelated books share, or a chapter number mistagged as the artist (`0.4`). +`Various Artists` is not a placeholder — that is a multi-cast read. + +The reviewed plan lives at `/mnt/entropy/media-relayout-audiobooks-plan.tsv`; it +applies on its own journal, behind the music writer, and then refreshes the Plex +section. Verify it with `--db-kind plex` — Jellyfin holds no rows here, so a +Jellyfin pass proves nothing either way. + +## See Also + +[[Media Relayout]], [[Media Music Relayout]], [[Plex]] diff --git a/serverAppConfigs-docs/Dankserver/Media Music Relayout.md b/serverAppConfigs-docs/Dankserver/Media Music Relayout.md new file mode 100644 index 0000000..77693fe --- /dev/null +++ b/serverAppConfigs-docs/Dankserver/Media Music Relayout.md @@ -0,0 +1,68 @@ +--- +type: reference +title: Media Music Relayout +description: "Re-filing /mnt/entropy/Music by Jellyfin's tag rows: the census, the album key, the refusals." +tags: [dankserver, media-server, jellyfin, media-relayout] +timestamp: 2026-10-31T00:00:00Z +--- + +# Media Music Relayout + +`/mnt/entropy/Music` is rips and discography packs, and Jellyfin has matched and +tagged almost all of it. `scripts/media-music.py` re-files it into the shape +Jellyfin's music scanner reads — + + Music// ()/NN - .ext + +— and the authority is the tag row of the track, never the name the ripper gave +the file. The shared gates, the executor, the journal law and the rollback live in +[[Media Relayout]]; this page is the music arm only. + +```bash +# 1. The authority: Jellyfin's audio rows, one per track, with the album and +# artist ids its own database carries (the DB opens read-only). +python3 scripts/media-census.py music --root /mnt/entropy/Music \ + --out /tmp/music_rows.tsv +# 2. What is on disk (stat only). +python3 scripts/media-census.py enumerate --root /mnt/entropy/Music \ + --out /tmp/music_enum.tsv +# 3. The plan. +python3 scripts/media-music.py --root /mnt/entropy/Music --enum /tmp/music_enum.tsv \ + --rows /tmp/music_rows.tsv --out /tmp/music_plan.tsv \ + --exceptions /tmp/music_exceptions.txt +# 4. Read the exceptions file FIRST — it is the review, not a log. Then the same +# executor, journal and rollback as [[Media Relayout]] shows, on its own journal. +scripts/media-apply.py apply --plan /tmp/music_plan.tsv --root /mnt/entropy \ + --journal /mnt/entropy/media-relayout-music.jsonl --apply +``` + +The group is what the tags assert, not where the files sit: **(album-artist tag, +album, year)**. One album arrives as three disc folders and as two copies of one +rip, and one MusicBrainz release group is one album. A compilation is one album +whose tracks name many performers, so it files once under `Various Artists`: +keying on the performer would file one album once per artist. Two different +album-artist tags on one album name are two albums, which is what Jellyfin's two +album items say. + +What it refuses: + ++- **A sidecar is placed by the audio around it, never by its own name.** A + `.lrc`, `.txt` or picture that shares a track's stem follows that track and + takes the name the track now has, or Jellyfin cannot find it; anything else + goes to the album its own folder was placed in, or to the folder whose discs + sit one level below it. A folder whose audio belongs to two albums parks its + sidecar rather than guessing. ++- **An image album keeps its name.** A folder holding one audio file and a + `.cue` is a whole album in one file; numbering it `01 - …` tells Jellyfin to + index it as one track. ++- **A video container parks**, because Jellyfin will not index it as music and a + rename to `.m4a` would claim it is; **audio no provider row names parks** too, + because unmatched audio is a tagging job, not a renaming job. + +ONE writer at a time on this array: two rename passes race, and neither journal +tells. Verify the run against Jellyfin's own tables — see *Proving the move +landed* in [[Media Relayout]]. + +## See Also + +[[Media Relayout]], [[Jellyfin]], [[Media Audiobook Relayout]] diff --git a/serverAppConfigs-docs/Dankserver/Media Relayout.md b/serverAppConfigs-docs/Dankserver/Media Relayout.md index ebf963b..2a28281 100644 --- a/serverAppConfigs-docs/Dankserver/Media Relayout.md +++ b/serverAppConfigs-docs/Dankserver/Media Relayout.md @@ -47,8 +47,10 @@ $C plex --root /mnt/entropy/Movies --out /tmp/plex-movies.tsv ``` `plex` reads the Plex metadata cache (`--db` defaults to the path the -[[Plex]] container bind-mounts at `/var/lib/plexmediaserver`) with -`mode=ro&immutable=1`: a live server is never locked or written. +[[Plex]] container bind-mounts at `/var/lib/plexmediaserver`) with plain +`mode=ro`: a live server is never locked or written, and plain ro is also what +makes a scan's uncheckpointed rows visible. The same rule as +`scripts/media-verify.py` — see *Proving the move landed*. Then plan — this reads only the three TSVs, so it costs seconds and can run anywhere, including a laptop: @@ -123,14 +125,30 @@ replayed against both servers' own tables: every `move` row has a row at its replay: it opens each database read-only (there is no `sqlite3` binary on these hosts or inside either container) and exits non-zero while a row is stale. +Both libraries live in WAL mode, so the tool opens them plain `mode=ro`: +`immutable=1` promises sqlite the file never changes, which skips the +write-ahead log — a scanner's own uncheckpointed rows then read as missing, and +a read beside a running scan can fail as "database disk image is malformed". +`immutable` stays only the fallback for a database whose `-shm` cannot be read. + ```bash scripts/media-verify.py --journal /mnt/entropy/media-relayout-music.jsonl \ --db-kind jellyfin --ignore-ext jpg,jpeg,png,log,cue,toc,nfo,txt,m3u,accurip ``` -Ignore only what a server cannot index. A missing `.flac` or `.mkv` is the -fault; a row still naming its `src` means the scan has not followed the rename -yet, so re-run the check after the scan, never before. +"No row at the destination" is not one verdict but four. **never indexed**: the +server indexes no file of that extension (its own extension census says so) — a +sidecar, not a lost film. **gone**: the file sits at neither src nor dst, because +a later pass or the server's own extension sweep renamed it. **phantom**: a row +still naming src while the file has left src. **REAL**: no row at dst *and* the +file still at src — the only class that is this relayout's own damage. Phantoms +are normal for both servers (Plex carries thousands that predate any relayout); +re-scan to clear them and never read them as loss. + +The owning server decides whether a pass can convict anything: Plex's roots are +its `sections` rows joined to their location rows (`Movies`, `TVShows`, `Music`, +`Audiobooks`, `LiveRecording`) and Jellyfin holds no audiobook library at all, so +the audiobook journal verifies with `--db-kind plex` or it proves nothing. Three doors that look right and are not: `GET /search?query=` matches metadata titles, never file paths; `?path=` on `/library/sections/N/all` makes Plex's @@ -167,16 +185,15 @@ from the box-set folder. What it refuses it prints and leaves alone: - a destination two files both claim: the executor refuses the whole plan before its first rename, and only Frank decides which copy is the keeper. - an episode the destination library already holds at the same byte count. The - movie share carried a second copy of the whole Monk box set — 124 episodes - both sides, the same episode list, every byte count identical, 31.5 GiB each - way, and head+tail blake2b identical on a 6-episode sample. The mover leaves - such a series in place and says so; a duplicate is deleted or kept by Frank, - never copied into one library by a tool. + movie share once carried a second copy of a whole box set — same episode list, + every byte count identical. The mover leaves such a series in place and says + so; a duplicate is deleted or kept by Frank, never copied into one library by + a tool. A sidecar that shares an episode's own name (`.srt`, `.nfo`) moves with it. Below the threshold the mover prints and leaves, so a lone downloaded episode of -a real show stays in the movie library on purpose — six did on 2026-10-30. +a real show stays in the movie library on purpose. Filing one by hand is the same executor on a hand-written plan and the same journal; the trap is the series year. Read it from the file's own name or the folder it owns, never from the title whose bonus folder it happens to be parked @@ -243,119 +260,12 @@ filename. Renaming a show is Jellyfin's work — a folder rename never touches i provider ids — so the mover keeps the name it is handed and strips only the pack index a release leads with (`4a. Seed DESTINY` → `Seed DESTINY`). -## The music library's own relayout +## The music and audiobook arms -`/mnt/entropy/Music` is rips and discography packs, and Jellyfin has matched and -tagged almost all of it. `scripts/media-music.py` re-files it into the shape -Jellyfin's music scanner reads — - - Music/<album artist>/<Album> (<year>)/NN - <Title>.ext - -— and the authority is the tag row of the track, never the name the ripper gave -the file. - -```bash -# 1. The authority: Jellyfin's audio rows, one per track, with the album and -# artist ids its own database carries (the DB opens read-only). -python3 scripts/media-census.py music --root /mnt/entropy/Music \ - --out /tmp/music_rows.tsv -# 2. What is on disk (stat only). -python3 scripts/media-census.py enumerate --root /mnt/entropy/Music \ - --out /tmp/music_enum.tsv -# 3. The plan. -python3 scripts/media-music.py --root /mnt/entropy/Music --enum /tmp/music_enum.tsv \ - --rows /tmp/music_rows.tsv --out /tmp/music_plan.tsv \ - --exceptions /tmp/music_exceptions.txt -# 4. Read the exceptions file FIRST — it is the review, not a log. Then the same -# executor, journal and rollback as above, on its own journal. -scripts/media-apply.py apply --plan /tmp/music_plan.tsv --root /mnt/entropy \ - --journal /mnt/entropy/media-relayout-music.jsonl --apply -``` - -The group is what the tags assert, not where the files sit: **(album-artist tag, -album, year)**. One album arrives as three disc folders and as two copies of one -rip, and one MusicBrainz release group is one album. A compilation is one album -whose tracks name many performers, so it files once under `Various Artists`: -keying on the performer would file one album once per artist. Two different -album-artist tags on one album name are two albums, which is what Jellyfin's two -album items say. - -What it refuses: - -+- **A sidecar is placed by the audio around it, never by its own name.** A - `.lrc`, `.txt` or picture that shares a track's stem follows that track and - takes the name the track now has, or Jellyfin cannot find it; anything else - goes to the album its own folder was placed in, or to the folder whose discs - sit one level below it. A folder whose audio belongs to two albums parks its - sidecar rather than guessing. -+- **An image album keeps its name.** A folder holding one audio file and a - `.cue` is a whole album in one file; numbering it `01 - …` tells Jellyfin to - index it as one track. -+- **A video container parks**, because Jellyfin will not index it as music and a - rename to `.m4a` would claim it is; **audio no provider row names parks** too, - because unmatched audio is a tagging job, not a renaming job. - -The plan applies on 2026-10-31 through `/mnt/entropy/media-relayout-music.jsonl`; -its rollback is that journal run through `media-apply.py rollback --apply`. ONE -writer at a time on this array: two rename passes race, and neither journal tells. - -## The audiobook library's own relayout - -`/mnt/entropy/Audiobooks` is the one library here that [[Jellyfin]] does not read -at all — its music scanner has no audiobook concept — while [[Plex]] indexes all -of it as Artist → Album → Track: the album is the BOOK, that book's single artist -is the NARRATOR, both matched out of the files' own tags. Today one book is split -across `… [Part 1]`, `… [Part 2]` folders three or four deep inside a collection -pack, so a scan serves three quarters of a novel; `scripts/media-audiobooks.py` -re-files the tree so the property both servers' book scanners assume is true — - - Audiobooks/<Narrator>/<Book> (<year>)/<the file keeps its own name> - -```bash -# 1. The authority: Plex's chapter rows for this root (the DB opens read-only). -python3 scripts/media-census.py audiobooks --root /mnt/entropy/Audiobooks \ - --out /tmp/ab_rows.tsv -# 2. What is on disk (stat only). -python3 scripts/media-census.py enumerate --root /mnt/entropy/Audiobooks \ - --out /tmp/ab_enum.tsv -# 3. The plan. -python3 scripts/media-audiobooks.py --root /mnt/entropy/Audiobooks \ - --enum /tmp/ab_enum.tsv --rows /tmp/ab_rows.tsv --out /tmp/ab_plan.tsv \ - --exceptions /tmp/ab_exceptions.txt -# 4. Read the exceptions file FIRST. Then the same executor, journal and rollback -# as above, on its own journal, and refresh the Plex section as in step 5 of -# the movie Procedure — from inside the container, never over the host port. -scripts/media-apply.py apply --plan /tmp/ab_plan.tsv --root /mnt/entropy \ - --journal /mnt/entropy/media-relayout-audiobooks.jsonl --apply -``` - -The group is the identity Plex asserts in its own database — **(narrator, book, -year)** — so one book arriving in four part folders becomes one folder, and a -1965 and a 2020 recording that share a title stay two books. - -Two laws are forced by what the rows actually hold: - -+- **A chapter is never renumbered.** Most books contain chapters whose index Plex - does not know, so a number written into a name would be invented — and it would - buy nothing, because both servers order playback by the embedded track tag, not - by the file name. A wrong number in a name is the number a human later trusts. - Fix order in the tags: a Plex scan re-reads tags and never renames a file. -+- **The shelf is the narrator, not the author.** Every book has exactly one artist - row and it is the narrator; the author survives only inside some album titles - (`Kryptos by Graham McNeill`) and in no column Plex stores, so deriving it would - mean parsing titles where a ripper wrote one and inventing it everywhere else. - The author stays visible in the folder name Plex itself shows. - -What it refuses, the music arm's list with one addition: a video container, audio -no chapter row names, and a loose file in a folder holding two books all park under -`_unsorted` with the path they have today — and so does a chapter whose row names -**no book or no narrator**: a placeholder (`[Unknown Album]`, `[Unknown Artist]`) -that unrelated books share, or a chapter number mistagged as the artist (`0.4`). -`Various Artists` is not a placeholder — that is a multi-cast read. - -The plan stands reviewed at `/mnt/entropy/media-relayout-audiobooks-plan.tsv` -(55,114 moves, 7,764 parked, executor preflight refused nothing); it applies on its -own journal, behind the music writer, and then refreshes the Plex section. +Each is a page of its own, because each reads a different authority and refuses +for a different reason: [[Media Music Relayout]] files by Jellyfin's track tags, +[[Media Audiobook Relayout]] by Plex's chapter rows. Both share this page's +executor, the one-journal-per-arm law, the sweep and the rollback. ## Laws @@ -437,16 +347,15 @@ own journal, behind the music writer, and then refreshes the Plex section. recreates the folder, so the sweep is undone by the same command as the moves. Proof carries downward: inside a folder the journal proves emptied, every empty directory goes with it. A release's `Screens/` or `Sample/` is never a - move source itself, so 146 of them held their emptied parents on disk after - the first sweep. + move source itself, so it can hold an emptied parent on disk after a sweep. A failed `rmdir` is not a removal: only a folder that is really gone leaves scope, so the next sweep finishes what a permission problem stopped. - **An episode-shaped name marks a SERIES, not a film — and the series is the name BEFORE the marker.** `Monk - S05E03 - Mr. Monk and the Big Game` is series - `Monk`. Keyed on the whole title instead, every episode became a group of one - and never reached the five-episode size that calls something a series, so a - box set was filed as 124 movies in 124 folders. The threshold still earns its - keep: a lone film named `Part 2` stays a film. Episodes of a series are parked + `Monk`. Keyed on the whole title instead, every episode became a group of one, + never reaching the five-episode size that calls something a series — a whole + box set filed as one folder per episode. The threshold still earns its keep: + a lone film named `Part 2` stays a film. Episodes of a series are parked in `_TV needs a TV library - <series>` for a human to move to a TV source — the movie tool never files TV. - **One journal is one undo.** The TV move runs on its own journal, and with @@ -489,6 +398,14 @@ any folder a sweep removed, restores every source path, removes only the folders that run created *and that are now empty*, and refuses everything if any moved file cannot be accounted for. A rolled-back journal refuses to roll back twice. +**Roll a chain back newest journal first, or the tool refuses.** Each pass +re-files what an earlier one moved, so a journal is a clean inverse only until +something downstream touches its files; `ls -l /mnt/entropy/media-relayout*.jsonl` +is the order. Refusing is the tool working: on 2026-10-31 the movie journal could +not restore files the later TV passes had re-filed, and the stray journal could +not restore files Jellyfin's extension sweep had renamed. Roll back what still +inverts and leave the rest as the current state — never force it. + ## See Also [[Jellyfin]], [[Plex]], [[Media Metadata View]], [[Entropy Volume]], [[Testing Policy]], [[Storage Policy]] diff --git a/serverAppConfigs-docs/index.md b/serverAppConfigs-docs/index.md index fda0026..a4b261d 100644 --- a/serverAppConfigs-docs/index.md +++ b/serverAppConfigs-docs/index.md @@ -51,7 +51,9 @@ is a locator: one row, one clause, no numbers. | [[Entropy Volume]] | The Dankserver entropy volume is a RAID-backed LUKS filesystem mounted at | | [[Jellyfin]] | Jellyfin on Dankserver: quadlet unit, overlay metadata view, provider keys, ports, access, transcode status. | | [[mariadb]] | The MariaDB backend for RomM: quadlet unit, network alias, env-file secret handling, and the backup path. | +| [[Media Audiobook Relayout]] | Re-filing /mnt/entropy/Audiobooks by Plex's chapter rows: narrator shelves, no renumbering, the refusals. | | [[Media Metadata View]] | The SSD-backed overlay the two media servers read and write: shape, guards, commands, laws, rollback. | +| [[Media Music Relayout]] | Re-filing /mnt/entropy/Music by Jellyfin's tag rows: the census, the album key, the refusals. | | [[Media Relayout]] | Census, plan and apply the movie, TV, music and audiobook libraries: the gates, the refusals, the rollback. | | [[Plex]] | Plex Media Server is a **containerized media server on Dankserver**: a podman | | [[redis]] | The Redis cache and task queue for RomM: quadlet unit, network alias topology, and the backup path. | From 4b67c3c4d8f214a82f6a04f651171855916a131f Mon Sep 17 00:00:00 2001 From: Frank <frank@dankware> Date: Sat, 31 Oct 2026 10:13:17 -0700 Subject: [PATCH 03/18] fix(media): the census read Plex the same wrong way MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two Plex arms of media-census.py — `plex` (the movie planner's title authority) and `audiobooks` (the chapter rows the audiobook plan files by) — opened the library `mode=ro&immutable=1` while the Jellyfin arms already used plain `mode=ro`. Same defect as media-verify.py: an uncheckpointed row of a running scan is invisible, so a just-scanned item is simply absent from the authority file a plan is built from, and the pass cannot say why. Both now go through open_plex_ro(), which tries plain ro first and keeps immutable only as the fallback for a database whose -shm cannot be read. The new audiobooks test leaves a chapter row committed-but-uncheckpointed and asserts the export still carries it; red before the change, green after. --- scripts/media-census.py | 36 +++++++++++++++++------------ tests/media_audiobooks.bats | 45 +++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 14 deletions(-) diff --git a/scripts/media-census.py b/scripts/media-census.py index fd9c5b3..a03251f 100755 --- a/scripts/media-census.py +++ b/scripts/media-census.py @@ -128,6 +128,26 @@ where p.file like ? and p.deleted_at is null and mi.deleted_at is null and al.deleted_at is null """ +def open_plex_ro(db): + """Plex's library, read the way Plex writes it. + + `immutable=1` tells sqlite the file never changes, so it skips the + write-ahead log: every row a scan has committed but not yet checkpointed + is invisible, and reading a live database that way can fail outright as + "database disk image is malformed". Plain ro reads the WAL through its + -shm; immutable is only the fallback for a database whose -shm cannot be + read. The same law scripts/media-verify.py states. + """ + last = None + for opt in ("mode=ro", "mode=ro&immutable=1"): + try: + return sqlite3.connect(f"file:{db}?{opt}", uri=True) + except sqlite3.Error as e: + last = e + sys.exit(f"cannot open the Plex database read-only ({db}): {last}\n" + "Plex runs rootless here: run this as the container's owner, never " + "with sudo — rootless storage is per-user, so root sees nothing.") + def enumerate_lib(root, out): """One row per real file under root: size, extension, path.""" @@ -227,13 +247,7 @@ def probe(enum, out, jobs): def plex(root, db, out): """Plex's matched titles for files under root, from its DB, read-only.""" - uri = f"file:{db}?mode=ro&immutable=1" - try: - con = sqlite3.connect(uri, uri=True) - except sqlite3.Error as e: - sys.exit(f"cannot open the Plex database read-only ({db}): {e}\n" - "Plex runs rootless here: run this as the container's owner, never " - "with sudo — rootless storage is per-user, so root sees nothing.") + con = open_plex_ro(db) with open(out, "w", newline="") as fh: w = csv.writer(fh, delimiter="\t", lineterminator="\n") w.writerow(PLEX_COLS) @@ -405,13 +419,7 @@ def audiobooks(root, db, out): at least one un-indexed chapter, so the relayout that reads this file must not renumber anything from it. """ - uri = f"file:{db}?mode=ro&immutable=1" - try: - con = sqlite3.connect(uri, uri=True) - except sqlite3.Error as e: - sys.exit(f"cannot open the Plex database read-only ({db}): {e}\n" - "Plex runs rootless here: run this as the container's owner, never " - "with sudo — rootless storage is per-user, so root sees nothing.") + con = open_plex_ro(db) tables = {r[0] for r in con.execute( "select name from sqlite_master where type='table'")} missing = {"media_parts", "media_items", "metadata_items"} - tables diff --git a/tests/media_audiobooks.bats b/tests/media_audiobooks.bats index 6f50db0..3214065 100755 --- a/tests/media_audiobooks.bats +++ b/tests/media_audiobooks.bats @@ -228,6 +228,51 @@ PY [ "$(wc -l < "$BATS_TEST_TMPDIR/exp.tsv")" -eq 1 ] # header only } +# wal_fresh DB ROOT OUT — a row Plex matched during a scan and committed, but +# has not checkpointed: the normal state of a library being walked. The holder +# connection stops the WAL folding into the main file when the writer closes, +# and the census runs as a real subprocess against those same files. +wal_fresh() { + local db="$1" root="$2" out="$3" + python3 - "$db" "$root" "$out" "$CENSUS" <<'PY' +import sqlite3, subprocess, sys +db, root, out, census = sys.argv[1:5] +w = sqlite3.connect(db) +w.execute("pragma journal_mode=wal") +w.execute("pragma wal_autocheckpoint=0") +w.commit() +w.execute("pragma wal_checkpoint(truncate)") # the rows so far are on disk +holder = sqlite3.connect(db) # last close would checkpoint +ar = w.execute('insert into metadata_items (parent_id, metadata_type, title,' + ' year, "index", deleted_at) VALUES (NULL, 8, ?, NULL, NULL, NULL)', + ("Fresh Narrator",)).lastrowid +al = w.execute('insert into metadata_items (parent_id, metadata_type, title,' + ' year, "index", deleted_at) VALUES (?, 9, ?, ?, NULL, NULL)', + (ar, "Fresh Book", 2026)).lastrowid +mi = w.execute('insert into metadata_items (parent_id, metadata_type, title,' + ' year, "index", deleted_at) VALUES (?, 10, ?, NULL, ?, NULL)', + (al, "Fresh Chapter", 1)).lastrowid +it = w.execute("insert into media_items (metadata_item_id, deleted_at)" + " VALUES (?, NULL)", (mi,)).lastrowid +w.execute("insert into media_parts (media_item_id, file, deleted_at)" + " VALUES (?, ?, NULL)", (it, root + "/Fresh/01.mp3")) +w.commit() +sys.exit(subprocess.run([sys.executable, census, "audiobooks", "--root", root, + "--db", db, "--out", out]).returncode) +PY +} + +@test "the census exports a row the scanner committed but has not checkpointed" { + DB="$BATS_TEST_TMPDIR/plex.db" + db_with_part "$DB" "$BATS_TEST_TMPDIR/Audiobooks/Old/01.mp3" \ + "Old Narrator" "Old Book" 1990 1 "Old Chapter" + run wal_fresh "$DB" "$BATS_TEST_TMPDIR/Audiobooks" "$BATS_TEST_TMPDIR/exp.tsv" + [ "$status" -eq 0 ] + [ "$(wc -l < "$BATS_TEST_TMPDIR/exp.tsv")" -eq 3 ] # header + both rows + tail -1 "$BATS_TEST_TMPDIR/exp.tsv" \ + | grep -q "^$BATS_TEST_TMPDIR/Audiobooks/Fresh/01\.mp3 Fresh Narrator Fresh Book 2026 1 Fresh Chapter$" +} + # -------------------------------------------------------------------- the plan @test "a matched chapter files under its narrator and its book, name untouched" { From a0dfa616234eb0268ab7399c4357f6aae7311eeb Mon Sep 17 00:00:00 2001 From: Frank <frank@dankware> Date: Sat, 31 Oct 2026 10:18:16 -0700 Subject: [PATCH 04/18] =?UTF-8?q?docs(media):=20the=20TVShows=20id-map=20p?= =?UTF-8?q?roposal=20=E2=80=94=20where=20it=20lives=20and=20what=20may=20b?= =?UTF-8?q?e=20applied=20unreviewed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- serverAppConfigs-docs/Dankserver/Media Relayout.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/serverAppConfigs-docs/Dankserver/Media Relayout.md b/serverAppConfigs-docs/Dankserver/Media Relayout.md index 2a28281..1b0a8c3 100644 --- a/serverAppConfigs-docs/Dankserver/Media Relayout.md +++ b/serverAppConfigs-docs/Dankserver/Media Relayout.md @@ -260,6 +260,18 @@ filename. Renaming a show is Jellyfin's work — a folder rename never touches i provider ids — so the mover keeps the name it is handed and strips only the pack index a release leads with (`4a. Seed DESTINY` → `Seed DESTINY`). +Release-group folder names are the one thing no provider can read, so the +pending TVShows naming plan files 179 top-level directories (9,931 files) under +the junk name they arrived with. A TVDB id map can name them, under the ladder's +own acceptance rule: a hit counts only when TVDB's spelling equals the cleaned +query, or differs from it by punctuation alone — the same letters are not a +guess. Everything else is a choice for Frank, and the map is never applied +unreviewed: `/mnt/entropy/media-relayout-shows-tvdb-proposal.tsv` carries one +row per folder with `verdict` = `EXACT` / `PUNCT` / `NEAR` / `NONE` and a `note` +naming the two traps — a folder marked `(US)` that matches the UK original, and +several folders mapping to one id, which is a MERGE whose duplicate episodes the +executor will refuse until a keeper is chosen. + ## The music and audiobook arms Each is a page of its own, because each reads a different authority and refuses From a5a9c04da2c52a1dba61804068157e171a756637 Mon Sep 17 00:00:00 2001 From: Frank <frank@dankware> Date: Sat, 31 Oct 2026 10:28:44 -0700 Subject: [PATCH 05/18] feat(media): the verdict splits what the database alone cannot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "No row at the destination" was one number, and it conflated three different things: a sidecar the server never indexes, a file a later pass renamed again, and a file that never left its source — the last one being actual damage. The runbook already named the four classes; the tool never computed them, so an operator reading `missing from the DB: 1851` had no way to tell a booklet.pdf from a lost album and might have run a rollback over a false alarm. `missing` rows are now stat'd on both paths and reported as `on disk at dst, no row names it` / `REAL — file still at src` / `gone — at neither path`, with offenders listed in that order. A row still naming `src` stays a separate line: that is the server's own stale pointer, a phantom, and it costs a re-scan rather than a restore. Two new tests (REAL, and gone-vs-sidecar) plus two existing layout assertions loosened from line positions to content. 14/14 in tests/media_verify.bats. --- scripts/media-verify.py | 34 ++++++++++++++++++++++++++++------ tests/media_verify.bats | 39 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/scripts/media-verify.py b/scripts/media-verify.py index 87e2861..346177c 100755 --- a/scripts/media-verify.py +++ b/scripts/media-verify.py @@ -27,9 +27,14 @@ beside a running scanner can even fail as "database disk image is malformed". Neither server holds a row for everything the journal renames: a `.log`, `.cue` or cover image has no row to land in, so its absence is not a fault. The report -therefore breaks the missing rows down by extension, and `--ignore-ext` names -the extensions this server does not index, which are then counted separately and -kept out of the verdict. +therefore breaks the missing rows down by extension, and `--ignore-ext` names the +extensions this server does not index, which are then counted separately and kept +out of the verdict. What is left is split by what the filesystem says, because +the database alone cannot: a file still at its `src` is `REAL` (this pass's own +damage), a file at neither path is `gone` (a later pass or the server renamed it), +and a file sitting at its `dst` with no row naming it is simply something the +server does not index. A row that still names `src` is none of these: it is the +server's own stale pointer — a phantom — and it costs a re-scan, never a restore. Exit codes: 0 every move landed; 1 something is stale or missing (the details are printed); 2 the journal or the database is not usable at all. @@ -174,20 +179,37 @@ def main(): if at_src: stale.append(src) if not at_dst: - missing.append(dst) + missing.append((src, dst)) + + # The database cannot tell a lost file from one it never indexes: both read + # as "no row at the destination". Stat both paths, so the report separates + # the classes the runbook names — `REAL` is the only one that is this pass's + # own damage, and `gone` is a later pass or the server's own rename. + on_disk, real, gone = [], [], [] + for src, dst in missing: + if os.path.exists(dst): + on_disk.append(dst) + elif os.path.exists(src): + real.append(src) + else: + gone.append(dst) print(f"{args.db_kind}: {len(moves)} moves checked") print(f"landed: {landed}") print(f"still at src: {len(stale)}") print(f"missing from the DB: {len(missing)}") + print(f" on disk at dst, no row names it: {len(on_disk)}") + print(f" REAL — file still at src: {len(real)}") + print(f" gone — at neither path: {len(gone)}") if ignored: print(f"not indexed (extension ignored): {ignored}") if missing: exts = Counter(os.path.splitext(d)[1].lower() or "(no extension)" - for d in missing) + for _, d in missing) print("missing by extension: " + ", ".join(f"{e} {n}" for e, n in sorted(exts.items()))) - for label, items in (("stale", stale), ("missing", missing)): + for label, items in (("real", real), ("gone", gone), ("missing", on_disk), + ("stale", stale)): for path in items[:args.show]: print(f" {label}: {path}") if len(items) > args.show: diff --git a/tests/media_verify.bats b/tests/media_verify.bats index a230dbf..340e32a 100755 --- a/tests/media_verify.bats +++ b/tests/media_verify.bats @@ -195,7 +195,7 @@ setup() { --share "$SHARE" --container /data [ "$status" -eq 1 ] [ "${lines[3]}" = "missing from the DB: 2" ] - [ "${lines[4]}" = "missing by extension: .log 1, .mkv 1" ] + [[ "$output" == *"missing by extension: .log 1, .mkv 1"* ]] } @test "--ignore-ext keeps a file the server never indexes out of the verdict" { @@ -208,7 +208,7 @@ setup() { [ "$status" -eq 0 ] [ "${lines[1]}" = "landed: 1" ] [ "${lines[3]}" = "missing from the DB: 0" ] - [ "${lines[4]}" = "not indexed (extension ignored): 1" ] + [[ "$output" == *"not indexed (extension ignored): 1"* ]] } # wal_case <db> — a Jellyfin database whose NEW row lives only in the @@ -245,3 +245,38 @@ PY [ "${lines[1]}" = "landed: 1" ] [ "${lines[3]}" = "missing from the DB: 0" ] } + +# The DB alone cannot tell a lost file from a sidecar: both are "no row at the +# destination". The tool therefore stats the two paths and separates the classes +# the runbook names, so an operator never reads a `.pdf` as a lost film or, +# worse, reads a real loss as a sidecar. +@test "a file the database does not name that still sits at src is the pass's own damage" { + mkdir -p "$SHARE/Movies" + echo film > "$SHARE/Movies/Old.mkv" + move_row "$SHARE/Movies/Old.mkv" "$SHARE/Movies/Old/Old.mkv" + + run python3 "$VERIFY" --journal "$JOURNAL" --db-kind jellyfin --db "$JFDB" \ + --share "$SHARE" --container /data + [ "$status" -eq 1 ] + [ "${lines[3]}" = "missing from the DB: 1" ] + [ "${lines[4]}" = " on disk at dst, no row names it: 0" ] + [ "${lines[5]}" = " REAL — file still at src: 1" ] + [ "${lines[6]}" = " gone — at neither path: 0" ] + [[ "$output" == *"real: $SHARE/Movies/Old.mkv"* ]] +} + +@test "a file at neither path is gone, and a file at dst the server ignores is not a loss" { + move_row "$SHARE/Movies/Gone.mkv" "$SHARE/Movies/Gone/Gone.mkv" + mkdir -p "$SHARE/Movies/Sidecar" + echo cover > "$SHARE/Movies/Sidecar/cover.jpg" + move_row "$SHARE/Movies/Album/cover.jpg" "$SHARE/Movies/Sidecar/cover.jpg" + + run python3 "$VERIFY" --journal "$JOURNAL" --db-kind jellyfin --db "$JFDB" \ + --share "$SHARE" --container /data + [ "$status" -eq 1 ] + [ "${lines[3]}" = "missing from the DB: 2" ] + [ "${lines[4]}" = " on disk at dst, no row names it: 1" ] + [ "${lines[5]}" = " REAL — file still at src: 0" ] + [ "${lines[6]}" = " gone — at neither path: 1" ] + [[ "$output" == *"gone: $SHARE/Movies/Gone/Gone.mkv"* ]] +} From dbcf8a2c357466877e51665262e71e98c09c59cb Mon Sep 17 00:00:00 2001 From: Frank <frank@dankware> Date: Sat, 31 Oct 2026 10:29:33 -0700 Subject: [PATCH 06/18] docs(media): the verifier prints the four verdict classes itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The taxonomy was written as something an operator assembled by hand; the tool now emits `on disk at dst` / `REAL` / `gone` lines, which is the difference between reading `missing from the DB: 1851` as 1851 lost albums and reading it correctly. cwd: /home/blank/.omp/wt/wt-20261030-163847-6d1fc1f · session: wt-20261030-163847-6d1fc1f --- serverAppConfigs-docs/Dankserver/Media Relayout.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/serverAppConfigs-docs/Dankserver/Media Relayout.md b/serverAppConfigs-docs/Dankserver/Media Relayout.md index 1b0a8c3..64d809a 100644 --- a/serverAppConfigs-docs/Dankserver/Media Relayout.md +++ b/serverAppConfigs-docs/Dankserver/Media Relayout.md @@ -145,6 +145,10 @@ file still at src — the only class that is this relayout's own damage. Phantom are normal for both servers (Plex carries thousands that predate any relayout); re-scan to clear them and never read them as loss. +`media-verify.py` prints this split itself — `on disk at dst, no row names it`, +`REAL — file still at src`, `gone — at neither path` — so nobody hand-stats a +thousand paths to find out whether a film is lost or merely unindexed. + The owning server decides whether a pass can convict anything: Plex's roots are its `sections` rows joined to their location rows (`Movies`, `TVShows`, `Music`, `Audiobooks`, `LiveRecording`) and Jellyfin holds no audiobook library at all, so From 2406641bc69aade0218a02603b7136a95d4ccc03 Mon Sep 17 00:00:00 2001 From: Frank <frank@dankware> Date: Sat, 31 Oct 2026 11:56:39 -0700 Subject: [PATCH 07/18] fix(dankserver): mount /mnt/entropy from its LUKS mapper at boot The crypttab entry named the md RAID array UUID (61bd6eaf-...) where a luks entry takes the LUKS container UUID (cd86c728-...), so systemd-cryptsetup@entropy.service could never produce /dev/mapper/entropy and the fstab entry could not resolve either; the volume only appeared when a desktop session let udisks2 unlock it, which kept the broken boot path hidden for a year. The keyfile was wrong as well: 27 bytes, i.e. the 26-character passphrase plus a trailing newline, and cryptsetup hashes the file whole. Records the host config as dankserver/host/{crypttab,fstab} (the dankclaw/host precedent), rewrites the Entropy Volume page to own the chain, the UUID law and the keyfile contract, and comments out the now-unmountable /dev/md0 line. Proven on the host: /etc/{crypttab,fstab} byte-identical to the repo copies, reboot into 7.0.0-42-generic mounts /mnt/entropy from /dev/mapper/entropy, all 12 containers returned, frank-archon back to 141 t/s (was 6.7 t/s on a wedged amdgpu queue). ./tests/run.sh: ALL TESTS PASSED (749). --- dankserver/host/crypttab | 1 + dankserver/host/fstab | 18 +++++ .../Dankserver/Entropy Volume.md | 81 +++++++++++-------- serverAppConfigs-docs/index.md | 2 +- 4 files changed, 69 insertions(+), 33 deletions(-) create mode 100644 dankserver/host/crypttab create mode 100644 dankserver/host/fstab diff --git a/dankserver/host/crypttab b/dankserver/host/crypttab new file mode 100644 index 0000000..ea78ef3 --- /dev/null +++ b/dankserver/host/crypttab @@ -0,0 +1 @@ +entropy UUID=cd86c728-459e-4f3a-bcc2-41ecf84685ec /etc/entropy nofail,luks diff --git a/dankserver/host/fstab b/dankserver/host/fstab new file mode 100644 index 0000000..c13dc3c --- /dev/null +++ b/dankserver/host/fstab @@ -0,0 +1,18 @@ +# /etc/fstab: static file system information. +# +# Use 'blkid' to print the universally unique identifier for a +# device; this may be used with UUID= as a more robust way to name devices +# that works even if disks are added and removed. See fstab(5). +# +# <file system> <mount point> <type> <options> <dump> <pass> +# / was on /dev/nvme0n1p2 during curtin installation +/dev/disk/by-uuid/4f3731d2-fb7b-48e7-a79f-0e33cb8a4162 / ext4 defaults 0 1 +# /boot/efi was on /dev/nvme0n1p1 during curtin installation +/dev/disk/by-uuid/183C-442D /boot/efi vfat defaults 0 1 +#/dev/disk/by-label/archive-one /mnt/archive-one auto nosuid,nodev,nofail,x-gvfs-show 0 0 +#/dev/disk/by-label/archive-two /mnt/archive-two auto nosuid,nodev,nofail,x-gvfs-show 0 0 +# /dev/md0 is a LUKS container, not a filesystem; its ext4 is mounted via +# /dev/mapper/entropy on the next line. Mounting /dev/md0 directly fails. +#/dev/md0 /mnt/md0 ext4 auto,nofail,nosuid,nodev 0 0 +/dev/mapper/entropy /mnt/entropy ext4 auto,nosuid,nofail,nodev,x-gvfs-show 0 0 +/swapfile none swap sw 0 0 diff --git a/serverAppConfigs-docs/Dankserver/Entropy Volume.md b/serverAppConfigs-docs/Dankserver/Entropy Volume.md index 8401caf..4e43f25 100644 --- a/serverAppConfigs-docs/Dankserver/Entropy Volume.md +++ b/serverAppConfigs-docs/Dankserver/Entropy Volume.md @@ -1,51 +1,68 @@ --- type: reference -description: "The Dankserver entropy volume is a RAID-backed LUKS filesystem mounted at" +description: "Dankserver's /mnt/entropy volume: the LUKS/array chain, the crypttab + fstab boot path, and the containers bound to it" tags: - dankserver -timestamp: 2026-08-28 +timestamp: 2026-10-31 --- # Entropy Volume -The Dankserver entropy volume is a RAID-backed LUKS filesystem mounted at -`/mnt/entropy`. The command-line helper reuses the host's desktop-integrated -`udisks2`, `crypttab`, and `fstab` configuration; it does not store or handle -the passphrase itself. +`/mnt/entropy` is the ext4 filesystem on the LUKS container holding `/dev/md0` +(RAID6, seven disks). The media stack's data lives there, so the mount is a boot +dependency for those containers rather than a convenience. -## Command-line mount - -```bash -/home/dank/serverAppConfigs/scripts/mount-entropy.sh -``` - -Run it as the normal desktop user (`dank`), not from a root shell. `udisksctl` -will securely prompt for the LUKS passphrase. `sudo mount /mnt/entropy` then -uses the existing `/etc/fstab` entry, preserving its options: +## Chain ```text -nosuid,nodev +/dev/md0 <- mdadm (its mdadm.conf ARRAY is what makes md0 exist at boot) + -> LUKS UUID=cd86c728-459e-4f3a-bcc2-41ecf84685ec keyfile: /etc/entropy + -> /dev/mapper/entropy <- systemd-cryptsetup@entropy.service + -> ext4 at /mnt/entropy <- mnt-entropy.mount, from /etc/fstab ``` -The existing mountpoint is owned by `dank:dankserver` with mode `0755`; the -helper deliberately does not run `chmod` or `chown`, so it cannot silently -change those permissions. +## Files that own it -## Device path +| Repo file | Installs to | Produces at boot | +|---|---|---| +| `dankserver/host/crypttab` | `/etc/crypttab` | `systemd-cryptsetup@entropy.service` → `/dev/mapper/entropy` | +| `dankserver/host/fstab` | `/etc/fstab` | `mnt-entropy.mount` (mountpoint `dank:dankserver`, `0755`) | -The current storage chain is: +Deploy a change by copying the file onto the host, then +`systemctl daemon-reload && update-initramfs -u`. The initramfs step is not +optional: the cryptsetup hook bakes `/etc/crypttab` into the initrd, and the +array is unlocked there, before the real root mounts. `scripts/mount-entropy.sh` +(`udisksctl unlock` + `mount`) stays as the manual path for a volume that was +never unlocked; on a correct boot it is redundant. -```text -/dev/md0 -> LUKS UUID cd86c728-459e-4f3a-bcc2-41ecf84685ec - -> ext4 label entropy - -> /mnt/entropy -``` +## Law -The helper checks that `/dev/md0` exists and exits successfully if the volume -is already mounted. It is intentionally specific to this host and volume: -that avoids accidentally unlocking or mounting a different disk. +**`crypttab`'s device field takes the LUKS UUID, never the md array UUID.** +`blkid` prints both on adjacent lines for a RAID-backed container, and the file +shipped since 2025 carried the array UUID (`61bd6eaf-…`) where the LUKS container +is `cd86c728-…`. The generator produced a perfectly valid unit pointing at a +device that never appears, so the mapper came into existence only through a +desktop `udisks2` session — the volume looked mounted on every login while every +boot failed to mount it. The matching `fstab` line named the same wrong UUID +directly instead of the mapper. -## Related +**A LUKS keyfile is hashed whole, so a trailing newline is a different +passphrase.** The shipped `/etc/entropy` was 27 bytes — 26 characters plus `\n` — +and `cryptsetup` rejected it even after the UUID was corrected. Keyfiles stay +`0600` with no trailing newline; prove it with +`cryptsetup open --test-passphrase --key-file /etc/entropy UUID=…` *before* +relying on it for a boot. -- [[Storage Policy]] -- [[Dankserver Stack]] +**Prove a mount fix with the artifacts systemd generates, not with a live +mount.** The regenerated `systemd-cryptsetup@entropy.service` naming the right +device, `findmnt /mnt/entropy` sourcing `/dev/mapper/entropy`, and a matching +checksum between the repo copy and the host copy are the evidence. A mount that +exists because someone logged in proves nothing about the boot path — that +exactly describes the year this bug survived. + +## Consumers + +`jellyfin`, `copyparty`, and `plex` bind paths under `/mnt/entropy`; with the +volume absent they start `active (healthy)` against empty directories, which is +how a missing 91 TiB volume hides. See [[Dankserver Stack]] for the stack and +[[Storage Policy]] for what may be written here. diff --git a/serverAppConfigs-docs/index.md b/serverAppConfigs-docs/index.md index a4b261d..32f830f 100644 --- a/serverAppConfigs-docs/index.md +++ b/serverAppConfigs-docs/index.md @@ -48,7 +48,7 @@ is a locator: one row, one clause, no numbers. | [[Calibre-Web]] | Calibre-Web is a web application that browses, reads, and downloads eBooks from an existing Calibre library database. | | [[Copyparty]] | Copyparty is a lightweight file server and media streamer that runs as a single Python binary. | | [[Dankserver Stack]] | Service inventory and routing overview for the media and library host. | -| [[Entropy Volume]] | The Dankserver entropy volume is a RAID-backed LUKS filesystem mounted at | +| [[Entropy Volume]] | Dankserver's /mnt/entropy volume: the LUKS/array chain, the crypttab + fstab boot path, and the containers bound to it | | [[Jellyfin]] | Jellyfin on Dankserver: quadlet unit, overlay metadata view, provider keys, ports, access, transcode status. | | [[mariadb]] | The MariaDB backend for RomM: quadlet unit, network alias, env-file secret handling, and the backup path. | | [[Media Audiobook Relayout]] | Re-filing /mnt/entropy/Audiobooks by Plex's chapter rows: narrator shelves, no renumbering, the refusals. | From acab4cef22ed9daccd93209e5876f98540c68d83 Mon Sep 17 00:00:00 2001 From: Frank <frank@dankware> Date: Sat, 31 Oct 2026 12:27:15 -0700 Subject: [PATCH 08/18] feat(media): the folder carries its tvdb id; specials and year-seasons resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects in the TVShows planner, all three visible in the applied plan: - A series folder named a show by words alone. Jellyfin's Tvdb agent and Plex's scanner both read `[tvdbid-NNNN]`, so the resolved id now rides the series folder — and only the folder: a file name gets 180 characters and the episode title needs the room. - 1,412 files parked because their provider row said `season 0`. Season 0 is a legal season; Jellyfin's contract is `Season 00` for specials. - 1,033 files parked because Jellyfin's date matcher writes the AIRDATE YEAR into the season column. Such a row is now renumbered by the title tvdb states for the series' id, read backwards off its own episode list. A title tvdb gives to two episodes names no episode, and that file parks with the reason it always carried. A season tvdb's own episode list CONTRADICTS — a release pack books more episodes there than the authority holds, the anime numbering hazard — keeps its rename and loses the tvdb title, because the slot its number points at is a different episode. Those series are listed in the exceptions file, which is where the operator reads them. Tests: six through the fixture (five red before the change; the ambiguous title is a guard that the recovery must never guess). Full bats suite green. --- scripts/media-shows.py | 115 ++++++++++++++++-- .../Dankserver/Media Relayout.md | 30 ++++- tests/media_shows.bats | 94 +++++++++++++- 3 files changed, 221 insertions(+), 18 deletions(-) diff --git a/scripts/media-shows.py b/scripts/media-shows.py index 6137661..a07222b 100755 --- a/scripts/media-shows.py +++ b/scripts/media-shows.py @@ -417,6 +417,49 @@ def tvdb_episodes(sid): for e in data.get("episodes") or []} return CACHE[ck] + + +# tvdb's titles read backwards, for the rows that carry a title and no numbering. +_TITLE_IDX = {} + + +def tvdb_title_index(sid): + """tvdb's episode list as `title -> (season, episode)`. A title two episodes + share names no episode: both halves are dropped, because a file that parks is + honest and a file filed at the wrong number is a lie the server believes.""" + if sid in _TITLE_IDX: + return _TITLE_IDX[sid] + index, tie = {}, set() + for key, name in (tvdb_episodes(sid) or {}).items(): + season, _, ep = key.partition("/") + t = eng.norm(name) + if not t or t in tie: + continue + if t in index: + del index[t] + tie.add(t) + continue + try: + n_season, n_ep = int(season), int(ep) + except ValueError: + continue # half-numbers ("12.5") state no slot + index[t] = (n_season, n_ep) + _TITLE_IDX[sid] = index + return index + + +def tvdb_number(t): + """The (season, episode) tvdb states for a provider row whose own numbering is + not a season at all. Jellyfin's date matcher files specials by AIRDATE, so the + row says season 2002 — a YEAR — and only the title the provider recorded says + which episode it is. No id, no title, or a title tvdb gives twice: no number, + and the file parks with the reason it always carried.""" + sid = (t.get("tvdb") or "").strip() + title = (t.get("title") or "").strip() + if not sid or not title: + return None + return tvdb_title_index(sid).get(eng.norm(title)) + # ------------------------------------------------------------------- the inputs rows = [] for line in open(ARGS.enum): @@ -613,9 +656,16 @@ for r in video: else: season = fs if fs is not None else num(t["season"]) ep = leading_index(r["base"]) or num(t["episode"]) - if not 0 < season <= 200 or ep <= 0: - unknown.append((r, f"a provider row of season {season} episode {ep}")) - continue + if not 0 <= season <= 200 or ep <= 0: + # Season 0 IS a season: specials live in Season 00 by Jellyfin's own + # contract. A season that is a YEAR is the date matcher's doing, and + # the only authority on it is tvdb's episode list for the series. + recovered = tvdb_number(t) + if not recovered: + unknown.append( + (r, f"a provider row of season {season} episode {ep}")) + continue + season, ep = recovered eps = [ep] claimants.append((r, owner, season, eps)) note(owner, t["year"] if t else "", t["tmdb"] if t else "", @@ -705,11 +755,38 @@ def label_of(s): return f"{nm} ({yr})" +def contradicted_seasons(s): + """Seasons whose numbering tvdb's own episode list contradicts — the one place + a tvdb title can be a confident lie. An anime pack numbered by its release group + books S05E01 in a season tvdb does not hold, or books more episodes there than + tvdb lists, and the slot its number points at is a DIFFERENT episode. Books <= + tvdb's count stays plausible: a partial pack is the normal shape.""" + if "contradicted" in s: + return s["contradicted"] + bad = set() + eps = tvdb_episodes(s["tvdb"]) if s["tvdb"] else None + if eps: + have = collections.Counter() + for k in eps: + season, _, _ep = k.partition("/") + if season.isdigit(): + have[int(season)] += 1 + booked = collections.defaultdict(set) + for _r, name, season, claimed in claimants: + if canon.get(series_key(name)) is s: + booked[season].update(claimed) + bad = {n for n, seen in booked.items() if len(seen) > have.get(n, 0)} + s["contradicted"] = bad + return bad + + def episode_title(r, s, season, ep): """The title this episode carries: tvdb's for the id the series resolved to, - else the title the matched row states, else nothing at all — a dangling ` - ` - is not a title, and an invented one is worse than none.""" - if s["tvdb"]: + else the title the matched row states, else nothing at all — a dangling ` - ` is + not a title, and an invented one is worse than none. A season tvdb's own list + contradicts gets no tvdb title: its number points somewhere else, and a wrong + title is worse than a bare one.""" + if s["tvdb"] and season not in contradicted_seasons(s): t = ((tvdb_episodes(s["tvdb"]) or {}).get(f"{season}/{ep}") or "").strip() if t: return clean(t) @@ -718,8 +795,17 @@ def episode_title(r, s, season, ep): def series_dir(name): + """One series, one folder — and the folder CARRIES the tvdb id. Both matchers + read `[tvdbid-NNNN]` (Jellyfin's Tvdb agent, Plex's scanner), so a show whose + title is ambiguous resolves by number instead of by a human guessing which + folder is which. The id rides the FOLDER only: Jellyfin gives a file name 180 + characters and the episode title needs the room. A series nothing resolved + keeps the provider's words with no tag at all.""" s = canon.get(series_key(name)) - return f"{ROOT}/{label_of(s)}" if s else f"{ROOT}/{clean(series_name(name))}" + if not s: + return f"{ROOT}/{clean(series_name(name))}" + tag = f" [tvdbid-{s['tvdb']}]" if s["tvdb"] else "" + return f"{ROOT}/{label_of(s)}{tag}" # ------------------------------------------------------------------- the plan @@ -973,6 +1059,8 @@ def write_plan(): def write_exceptions(): no_year = sorted({s["name"] for s in canon.values() if not s["year"]}) no_tvdb = sorted({identity(s)[0] for s in canon.values() if not s.get("canon")}) + clash = sorted({(identity(s)[0], tuple(sorted(contradicted_seasons(s)))) + for s in canon.values() if contradicted_seasons(s)}) with open(ARGS.exceptions, "w") as fh: fh.write(f"TV relayout exceptions for {ROOT}\n\n") fh.write(f"Everything below is parked under {PARK}, keeping the relative path it\n") @@ -1002,6 +1090,19 @@ def write_exceptions(): fh.write(" title was taken. Nothing was invented: give these a resolvable\n") fh.write(" id (--tvdb) and re-plan; a rename is idempotent.\n") fh.write(" " + ", ".join(no_tvdb[:300]) + "\n") + if clash: + fh.write(f"\n{len(clash):>6} series carry seasons whose numbering tvdb" + " contradicts:\n") + fh.write(" a pack books more episodes in a season than tvdb holds" + " there — an anime\n pack numbered by its release group is" + " the usual shape. Those files still\n rename to the" + " standard shape, but they do NOT ride with tvdb's titles:" + "\n the slot tvdb answers for that number is a different" + " episode. Correct the\n numbering and re-plan; a rename" + " is idempotent.\n") + fh.write(" " + "; ".join( + f"{n} S{'/'.join(f'{x:02d}' for x in ss)}" + for n, ss in clash[:300]) + "\n") print(f"exceptions -> {ARGS.exceptions}") diff --git a/serverAppConfigs-docs/Dankserver/Media Relayout.md b/serverAppConfigs-docs/Dankserver/Media Relayout.md index d3ecab2..c7c3fb6 100644 --- a/serverAppConfigs-docs/Dankserver/Media Relayout.md +++ b/serverAppConfigs-docs/Dankserver/Media Relayout.md @@ -181,7 +181,8 @@ show. A name whose marker is `Part 2` is a film and stays put. already matched and numbered most of it. `scripts/media-shows.py` re-files that library into the shape Jellyfin reads, with **tvdb as the name authority**: - <Series> (<year>)/Season NN/<Series> (<year>) - SxxEyy - <Episode Title>.ext + <Series> (<year>) [tvdbid-NNNN]/Season NN/ + <Series> (<year>) - SxxEyy - <Episode Title>.ext It never guesses: every series name, episode number and title it writes traces to an authority, and what has none is parked for a human to read. @@ -234,7 +235,11 @@ Evidence, strongest first — the order the planner believes things: whose own name says nothing; - the **row's tvdb id, else its TMDb id carried through TMDb's external ids, else an exact tvdb name search**: that id names the SERIES FOLDER and supplies the - episode title, because a name the server read off a folder is not a show name; + episode title, because a name the server read off a folder is not a show name. + The folder CARRIES the id: `[tvdbid-NNNN]` is what Jellyfin's Tvdb agent and + Plex's scanner both read, so a show whose title is ambiguous resolves by number + instead of by a human guessing which folder is which. The id rides the folder + only — a file name gets 180 characters and the episode title needs the room. - a **bonus folder** anywhere above the file, or Jellyfin's `ExtraType` → the series' canonical extra folder; - a runtime over `SHORT_MAX` makes it film-shaped, and it leaves for `--movies` only @@ -244,7 +249,7 @@ Evidence, strongest first — the order the planner believes things: for the word "russia", *Special Edition I* for a file saying *Edition III*. Else it parks. -Two refusals carry the design, and a dry run on the real library found both: +Four refusals carry the design: what the planner will NOT do is the point of it. - **Folders resolve a series; they never invent one.** A folder's words may resolve a file to a series a provider has already named — `(CBB) Tengen Toppa @@ -254,8 +259,23 @@ Two refusals carry the design, and a dry run on the real library found both: - **A scraped row does not outrank the filesystem.** Jellyfin numbers multi-season packs badly: every file under `SNL 16-20/Season 16..19` carries season 16 episode 20, and one Gundam row says season 2004. The folder, then - the file's own leading number, win; a row that still insists on season 1989 - parks its file instead of creating `Season 1989`. + the file's own leading number, win. +- **A season that is a YEAR is the date matcher, not a season.** Jellyfin files + specials by airdate, so a row can arrive saying `season 2002`; that is not a + season and it never becomes a folder. The one authority on such a row is tvdb's + episode list read backwards: the title the row carries names the (season, + episode) tvdb gives it. A title tvdb gives to two episodes names nothing, and + the file parks with the reason it always carried. `Season 00` IS a legal + season — specials live there — so a row that really says 0 files there instead + of parking. +- **A season tvdb's own list contradicts gets no tvdb title.** An anime pack + numbered by its release group books `S05E01` in a season tvdb does not hold, or + books more episodes there than tvdb lists: the slot its number points at is a + different episode, and dressing it in that episode's title is a confident lie. + The file still renames to the standard shape; the title does not ride, and the + series is listed under *seasons whose numbering tvdb contradicts* in the + exceptions. A season holding FEWER files than tvdb lists stays plausible — a + partial pack is the normal shape. **A provider name is evidence only when an id backs it.** Jellyfin names a Series after the folder it scanned, so a library filed by release folders ends up with diff --git a/tests/media_shows.bats b/tests/media_shows.bats index 043dc8a..1ef2f42 100755 --- a/tests/media_shows.bats +++ b/tests/media_shows.bats @@ -11,7 +11,11 @@ # the name caches are input files, so no test touches the network. # # The target contract is tvdb-grade naming: -# <Series> (<year>)/Season NN/<Series> (<year>) - SxxEyy - <Episode Title>.<ext> +# <Series> (<year>) [tvdbid-NNNN]/Season NN/<Series> (<year>) - SxxEyy - <Title>.<ext> +# The id rides the FOLDER only: it is what both matchers read the series by, and a +# 180-character ceiling on a file name is not the place to spend it. Season 00 +# holds specials; a provider row whose "season" is a YEAR is renumbered by the +# episode title tvdb states for the series' id, never by a guess. # tvdb is the IDENTITY authority: a provider name a server derived from a folder # is not a series name, so a polluted name is undone through the series' tvdb id. # The review pile parks OUTSIDE the library — a parked folder inside it indexes @@ -39,6 +43,15 @@ title_row() { "${5:-}" "${6:-}" "${7:-}" "${8:-}" "${9:-}" >> "$TITLES" } +# file PATH SIZE — `at` plus the row the enum census would have carried had the +# file existed when setup walked the tree. A test that needs a shape the fixture +# does not ship adds it here, or the planner never sees it. +file() { + at "$1" "$2" + local b="${1##*/}" + printf '%s\t.%s\t%s\n' "$2" "${b##*.}" "$1" >> "$ENUM" +} + # dst SRC — the destination the plan gives SRC, or nothing when it does not move. dst() { awk -F '\t' -v s="$1" '$1 == "move" && $2 == s {print $3}' "$OUT" @@ -220,11 +233,12 @@ PY = "$LIB/The Examples (2010)/Season 01/The Examples (2010) - S01E01 - Pilot.mkv" ] } -@test "the tvdb identity names the series folder, not the name a folder left behind" { +@test "the tvdb identity names the series folder, id and all, not the folder's echo" { printf '{"tvdb:series:78850": {"name": "Show Box", "year": "2016"}}\n' > "$CACHE" plan - [ "$(dst "$BOX/Show.Box.S01E01.1080p.WEB-DL.mkv")" \ - = "$LIB/Show Box (2016)/Season 01/Show Box (2016) - S01E01 - Pilot.mkv" ] + want="$LIB/Show Box (2016) [tvdbid-78850]/Season 01" + want+="/Show Box (2016) - S01E01 - Pilot.mkv" + [ "$(dst "$BOX/Show.Box.S01E01.1080p.WEB-DL.mkv")" = "$want" ] } @test "a provider name that already carries the year is not given it twice" { @@ -232,8 +246,9 @@ PY # and `Acapulco (2021) (2021)` is a folder neither matcher nor human reuses. printf '{"tvdb:series:78850": {"name": "Show Box (2016)", "year": "2016"}}\n' > "$CACHE" plan - [ "$(dst "$BOX/Show.Box.S01E01.1080p.WEB-DL.mkv")" \ - = "$LIB/Show Box (2016)/Season 01/Show Box (2016) - S01E01 - Pilot.mkv" ] + want="$LIB/Show Box (2016) [tvdbid-78850]/Season 01" + want+="/Show Box (2016) - S01E01 - Pilot.mkv" + [ "$(dst "$BOX/Show.Box.S01E01.1080p.WEB-DL.mkv")" = "$want" ] run grep -F '(2016) (2016)' "$OUT" [ -z "$output" ] } @@ -457,3 +472,70 @@ PY plan [ -z "$(awk -F '\t' '$1 == "move" {print $2}' "$OUT")" ] } + +# Specials and the year-shaped season. Jellyfin's contract puts specials in +# Season 00, and its date matcher hands back rows whose "season" is the AIRDATE +# YEAR. Both used to park — 1,412 and 1,033 files of one 52k plan — although one +# is a legal season and the other is renumberable from the title tvdb states. +@test "a provider's season 0 is a special, so it files under Season 00" { + file "$LIB/Show Bumps/Best Of The Examples.mkv" 5000 + title_row "$LIB/Show Bumps/Best Of The Examples.mkv" "The Examples" 2010 0 2 + plan + [ "$(dst "$LIB/Show Bumps/Best Of The Examples.mkv")" \ + = "$LIB/The Examples (2010)/Season 00/The Examples (2010) - S00E02.mkv" ] +} + +# These three tests carry a tvdb id of their own. The fixture's box set is 78850 and +# ONE id means ONE show: reusing it here would merge these files into that series and +# the duplicate rule would park them before any numbering question is asked. +@test "a row whose season is a year is renumbered by the title tvdb states" { + printf '{"tvdb:series:4242": %s, "tvdb:episodes:4242": %s}\n' \ + '{"name": "The Examples", "year": "2010"}' \ + '{"1/7": "Best Of"}' > "$CACHE" + file "$LIB/Show Bumps/Best Of The Examples.mkv" 5000 + title_row "$LIB/Show Bumps/Best Of The Examples.mkv" \ + "The Examples" 2010 2010 11 "" "" "Best Of" "4242" + plan + want="$LIB/The Examples (2010) [tvdbid-4242]/Season 01" + want+="/The Examples (2010) - S01E07 - Best Of.mkv" + [ "$(dst "$LIB/Show Bumps/Best Of The Examples.mkv")" = "$want" ] +} + +@test "a title tvdb gives twice never renumbers an episode" { + printf '{"tvdb:series:4242": %s, "tvdb:episodes:4242": %s}\n' \ + '{"name": "The Examples", "year": "2010"}' \ + '{"1/7": "Best Of", "2/3": "Best Of"}' > "$CACHE" + file "$LIB/Show Bumps/Best Of The Examples.mkv" 5000 + title_row "$LIB/Show Bumps/Best Of The Examples.mkv" \ + "The Examples" 2010 2010 11 "" "" "Best Of" "4242" + plan_park + run awk -F '\t' -v p="$OUTSIDE/" '$1 == "move" && index($3, p) == 1 {print $3}' "$OUT" + [[ "$output" == *"Best Of The Examples.mkv"* ]] +} + +# Anime numbering is the one place a tvdb title can be confidently wrong: a pack +# numbered by its release group still names S05E01, and tvdb answers that slot with +# a different episode. A season the authority does not hold, or holds fewer of than +# the pack books, is CONTRADICTED: the rename still happens, the title does not. +@test "a season tvdb contradicts keeps the rename but never its titles" { + printf '{"tvdb:series:4242": %s, "tvdb:episodes:4242": %s}\n' \ + '{"name": "Anime Gate", "year": "2010"}' \ + '{"1/1": "Pilot"}' > "$CACHE" + file "$LIB/Gate Pack/Season 01/one S01E01.mkv" 5000 + file "$LIB/Gate Pack/Season 05/two S05E01.mkv" 5000 + file "$LIB/Gate Pack/Season 05/three S05E02.mkv" 5000 + title_row "$LIB/Gate Pack/Season 01/one S01E01.mkv" \ + "Anime Gate" 2010 1 1 "" "" "" "4242" + title_row "$LIB/Gate Pack/Season 05/two S05E01.mkv" \ + "Anime Gate" 2010 5 1 "" "" "" "4242" + title_row "$LIB/Gate Pack/Season 05/three S05E02.mkv" \ + "Anime Gate" 2010 5 2 "" "" "" "4242" + plan + want="$LIB/Anime Gate (2010) [tvdbid-4242]/Season 01" + want+="/Anime Gate (2010) - S01E01 - Pilot.mkv" + [ "$(dst "$LIB/Gate Pack/Season 01/one S01E01.mkv")" = "$want" ] + want="$LIB/Anime Gate (2010) [tvdbid-4242]/Season 05" + want+="/Anime Gate (2010) - S05E01.mkv" + [ "$(dst "$LIB/Gate Pack/Season 05/two S05E01.mkv")" = "$want" ] + grep -A6 -i "numbering tvdb contradicts" "$EXC" | grep -q "Anime Gate" +} From e4656cfd7a64f0955a55825c24b78a47a61ea17e Mon Sep 17 00:00:00 2001 From: Frank <frank@dankware> Date: Sat, 31 Oct 2026 12:40:49 -0700 Subject: [PATCH 09/18] fix(media): a tvdb episode page is not the episode list Tvdb's default episode query answers at most 500 rows and states no total. One Piece (1999), Crayon Shin-chan (1992) and Detective Conan (1996) each answer exactly 500 while holding over a thousand, so the contradiction rule added in the previous commit read every long-running show as contradicted: the pack books more episodes than "the authority holds", and the rule that exists to stop a wrong anime title would have stripped the right one from every file of the season. A capped list now records that it is capped (`tvdb:capped:<id>`, so a warm cache answers the same question a cold one did) and contradicts nothing; titles for the episodes the page does hold still ride. Test: a 500-row page with a 501-episode pack keeps ` - Episode 1` and lists no contradiction. Red before the change, proved in a copy of the tree with the cap raised past the fixture. --- scripts/media-shows.py | 48 +++++++++++++------ .../Dankserver/Media Relayout.md | 6 ++- tests/media_shows.bats | 27 +++++++++++ 3 files changed, 66 insertions(+), 15 deletions(-) diff --git a/scripts/media-shows.py b/scripts/media-shows.py index a07222b..4dff323 100755 --- a/scripts/media-shows.py +++ b/scripts/media-shows.py @@ -404,20 +404,37 @@ def tvdb_search(name): return None +# Tvdb's default episode query answers AT MOST this many rows and states no +# total: One Piece (1999), Crayon Shin-chan (1992) and Detective Conan (1996) +# each answer exactly 500 while holding over a thousand. A list that long bounds +# nothing above itself, so it must never be read as "the series holds 500". +EPISODE_PAGE_CAP = 500 + + def tvdb_episodes(sid): - """`season/episode` -> title for one series, in the one call tvdb answers.""" + """`season/episode` -> title for one series, in the one call tvdb answers. + Whether that call came back a cut-off page is recorded in the cache beside + it, so a warm cache answers the same question a cold one did.""" ck = f"tvdb:episodes:{sid}" - if ck in CACHE: - return CACHE[ck] - data = tvdb(f"/series/{sid}/episodes/default") - if not data: - return None - CACHE[ck] = {f"{e.get('seasonNumber')}/{e.get('number')}": - (e.get("name") or "").strip() - for e in data.get("episodes") or []} + if ck not in CACHE: + data = tvdb(f"/series/{sid}/episodes/default") + if not data: + return None + rows = data.get("episodes") or [] + CACHE[ck] = {f"{e.get('seasonNumber')}/{e.get('number')}": + (e.get("name") or "").strip() for e in rows} + CACHE[f"tvdb:capped:{sid}"] = len(rows) >= EPISODE_PAGE_CAP return CACHE[ck] +def tvdb_list_capped(sid): + """True when tvdb's episode answer for this series is a page, not a list.""" + rows = tvdb_episodes(sid) + if not rows: + return False + return CACHE.get(f"tvdb:capped:{sid}", len(rows) >= EPISODE_PAGE_CAP) + + # tvdb's titles read backwards, for the rows that carry a title and no numbering. _TITLE_IDX = {} @@ -757,14 +774,17 @@ def label_of(s): def contradicted_seasons(s): """Seasons whose numbering tvdb's own episode list contradicts — the one place - a tvdb title can be a confident lie. An anime pack numbered by its release group - books S05E01 in a season tvdb does not hold, or books more episodes there than - tvdb lists, and the slot its number points at is a DIFFERENT episode. Books <= - tvdb's count stays plausible: a partial pack is the normal shape.""" + a tvdb title can be a confident lie. An anime pack numbered by its release group + books S05E01 in a season tvdb does not hold, or books more episodes there than + tvdb lists, and the slot its number points at is a DIFFERENT episode. Books <= + tvdb's count stays plausible: a partial pack is the normal shape. A list the + authority cut off at its page limit (EPISODE_PAGE_CAP) contradicts nothing — + it says nothing about the episodes it never sent.""" if "contradicted" in s: return s["contradicted"] bad = set() - eps = tvdb_episodes(s["tvdb"]) if s["tvdb"] else None + eps = None if not s["tvdb"] or tvdb_list_capped(s["tvdb"]) \ + else tvdb_episodes(s["tvdb"]) if eps: have = collections.Counter() for k in eps: diff --git a/serverAppConfigs-docs/Dankserver/Media Relayout.md b/serverAppConfigs-docs/Dankserver/Media Relayout.md index c7c3fb6..a1ad5f1 100644 --- a/serverAppConfigs-docs/Dankserver/Media Relayout.md +++ b/serverAppConfigs-docs/Dankserver/Media Relayout.md @@ -275,7 +275,11 @@ Four refusals carry the design: what the planner will NOT do is the point of it. The file still renames to the standard shape; the title does not ride, and the series is listed under *seasons whose numbering tvdb contradicts* in the exceptions. A season holding FEWER files than tvdb lists stays plausible — a - partial pack is the normal shape. + partial pack is the normal shape. Neither reading holds for a list tvdb + cut off at its own page limit: the default episode query answers at most + 500 rows and states no total, and One Piece, Crayon Shin-chan and + Detective Conan each answer exactly 500 while holding over a thousand — + a page bounds nothing above itself, so it contradicts nothing. **A provider name is evidence only when an id backs it.** Jellyfin names a Series after the folder it scanned, so a library filed by release folders ends up with diff --git a/tests/media_shows.bats b/tests/media_shows.bats index 1ef2f42..e78bf1d 100755 --- a/tests/media_shows.bats +++ b/tests/media_shows.bats @@ -539,3 +539,30 @@ PY [ "$(dst "$LIB/Gate Pack/Season 05/two S05E01.mkv")" = "$want" ] grep -A6 -i "numbering tvdb contradicts" "$EXC" | grep -q "Anime Gate" } + +# Tvdb's default episode query answers at most 500 rows and states no total, so a +# list that long is a PAGE, not a list: One Piece (1999), Crayon Shin-chan (1992) +# and Detective Conan (1996) each answer exactly 500 while holding over a thousand. +# Read as the whole truth, that page contradicts every long-running show's pack +# and strips the tvdb title from all of it. +@test "an episode list the authority paginates never contradicts the pack" { + python3 - "$CACHE" <<'PY' +import json, sys +json.dump({"tvdb:series:4242": {"name": "Long Run", "year": "1999"}, + "tvdb:episodes:4242": {f"1/{n}": f"Episode {n}" + for n in range(1, 501)}}, + open(sys.argv[1], "w")) +PY + for n in $(seq 1 501); do + ep=$(printf '%02d' "$n") + file "$LIB/Long Pack/Season 01/long $ep.mkv" 1000 + title_row "$LIB/Long Pack/Season 01/long $ep.mkv" \ + "Long Run" 1999 1 "$n" "" "" "" "4242" + done + plan + want="$LIB/Long Run (1999) [tvdbid-4242]/Season 01" + want+="/Long Run (1999) - S01E01 - Episode 1.mkv" + [ "$(dst "$LIB/Long Pack/Season 01/long 01.mkv")" = "$want" ] + run grep -i "numbering tvdb contradicts" "$EXC" + [ "$status" -ne 0 ] +} From 7aa32d1b6498764ce5b9f5a51786fe679cdcc96f Mon Sep 17 00:00:00 2001 From: Frank <frank@dankware> Date: Sat, 31 Oct 2026 13:10:32 -0700 Subject: [PATCH 10/18] feat(media): the operator's map gives a folder the id its name cannot reach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tvdb titles a record in the show's own language and keeps the English name in aliases the name search never reads, so the rule that accepts only tvdb's own spelling of a query can never resolve them: ブラッククローバー (331753) holds no alias at all, and the bare alias "One Piece" belongs to 392276, the 2023 live action, not to the 1999 record (81797) that 1,072-file pack is. Seventeen pack folders on the array — 3,507 files — are in that class. media-shows.py now reads scripts/media-shows-tvdb-ids.tsv by default: one line per pack folder, folder <TAB> tvdb id <TAB> note, every id read from tvdb's own record. The map outranks a provider id, because a box set's provider id is the server's own guess at a folder name, and it merges the release folders that are one show. Everything it touched goes to the exceptions: every folder it named, every row that matched no folder, every disagreement with a provider id, every id tvdb never answered. The file is read fail-closed — an id that is not a number, or one folder named twice, stops the plan, because a typo there renames a pack of 1,268 files to the wrong show. scripts/media-shows-tvdb.py is the sheet the map was built from, promoted out of /tmp so the next run is reproducible: it reads a plan's mkdir rows, asks tvdb about every destination folder carrying no [tvdbid-], and grades the answer EXACT / PUNCT / NEAR / NONE by how much was guessed to reach it. Acceptance is unchanged from the passes it replaces — bare-form equality plus the year-span guard — and that gate is what rejected three confident picks: Fairy Tail's 2024 sequel (410031), the 2026 Hokuto no Ken remake (465216) and Mugyutto! Black Clover (378762) offered for Black Clover. The sheet is for the operator; the map is what the planner reads, and only after he approves the rows. Tests: seven for the map's behaviour — rename to the record it names, two release folders becoming one series, precedence over a provider id and the line stating the disagreement, an id tvdb never answered, a row whose folder is gone, a malformed id refusing the run, a folder named twice refusing the run — plus one that the shipped file parses and never stops a plan. --- scripts/media-shows-tvdb-ids.tsv | 35 ++ scripts/media-shows-tvdb.py | 306 ++++++++++++++++++ scripts/media-shows.py | 115 ++++++- .../Dankserver/Media Relayout.md | 40 ++- tests/media_shows.bats | 129 ++++++++ 5 files changed, 609 insertions(+), 16 deletions(-) create mode 100644 scripts/media-shows-tvdb-ids.tsv create mode 100755 scripts/media-shows-tvdb.py diff --git a/scripts/media-shows-tvdb-ids.tsv b/scripts/media-shows-tvdb-ids.tsv new file mode 100644 index 0000000..f840f8f --- /dev/null +++ b/scripts/media-shows-tvdb-ids.tsv @@ -0,0 +1,35 @@ +# The operator's tvdb id map: `folder <TAB> tvdb id <TAB> note`. +# +# These are the pack folders whose show tvdb's NAME SEARCH cannot reach: the +# record exists, but tvdb titles it in the show's own language and the English +# name rides in aliases the search never reads. Without a row here the folder +# keeps the release folder's words and no episode title; with one, both matchers +# read `[tvdbid-NNNN]` and the files rename to tvdb's own name and titles. +# +# Every id was read from tvdb's own record (`/series/<id>/extended`) on +# 2026-10-31, and the note states what that record says plus the neighbouring +# record a name search would have picked instead — the row is checkable without +# re-searching, because one wrong id renames a whole pack to the wrong show. The +# plan refuses a line whose id is not a number and lists every folder the map +# touched in the exceptions file. +# +# Folders the name search DOES reach (the EXACT and PUNCT classes of the proposal +# table) stay off this file on purpose: a map row would hide a live lookup behind +# a number nobody re-checks. +[Anime Time] Pokemon Complete Series (Season 01-25+Movies+Specials+Shorts+OST) [DVD+BD][1080p][HEVC 10bit x265][AAC] 76703 tvdb: ポケットモンスター (1997), the umbrella record for every era this pack spans; 1268 files +[Anime Time] One Piece (0001-1071+Movies+Specials) [BD+CR] [Dual Audio] [1080p][HEVC 10bit x265][AAC][Multi Sub] 81797 tvdb: ワンピース (1999), alias ONE PIECE; the bare alias "One Piece" belongs to 392276, the 2023 live action; 1072 files +[Judas] Naruto (Complete Series) [BD 1080p][HEVC x265 10bit][Multi-Audio][Multi-Subs] 78857 tvdb: NARUTO-ナルト- (2002), alias "Naruto" carries the full-width dashes; 220 files +Star Trek Deep Space 9 Deluxe DVD Boxset + Extras DVDRip TSV 72073 tvdb: Star Trek: Deep Space Nine (1993), alias "Star Trek Deep Space 9"; the box set's own words match nothing; 279 files +Top Gear UK 1-17 74608 tvdb: Top Gear (2002), no "UK" alias — the US version is 199051; 131 files +Black Clover 001 331753 tvdb: ブラッククローバー (2017), which holds NO alias at all; 378762 is Mugyutto! Black Clover (2019), a different show; 108 files +Shin Chan - Complete Eng Dub Series 2160p Ai Upscaled -Mesc 437356 tvdb: Shin Chan (2006) — the English dub record this pack is; 79654 is the 1992 Japanese original; 78 files +Fairy Tail (2009-2014) (Seasons 1-8 + OVAs) (BD 1080p)(HEVC x265 10bit)(Dual-Audio)(Eng-Subs)-Judas[TGx] 114801 tvdb: フェアリーテイル (2009), alias FAIRY TAIL; 410031 is FAIRY TAIL 100 NEN QUEST (2024), the sequel; 59 files +(CBB) Tokyo Ghoul - The Complete Series (BD 1080p)(Dual-Audio)(HEVC-10bit-x265) 281249 tvdb: 東京喰種トーキョーグール (2014) — :re and √A are seasons of THIS record, confirmed by search; 58 files +[Anime Time] Demon Slayer - Kimetsu no Yaiba (S01+S02+S03+S04+Mugen Train Arc+Movie) [BD] [Dual Audio][1080p][HEVC 10bit x265][AAC][Eng Sub] 348545 tvdb: 鬼滅の刃 (2019), alias "Demon Slayer: Kimetsu no Yaiba" — the folder's hyphen splits it differently; 56 files +(CBB) Tengen Toppa Gurren Lagann - The Complete Series (BD 1080p)(Dual-Audio)(HEVC-x265-10bit) 80096 tvdb: 天元突破グレンラガン (2007), alias Tengen Toppa Gurren Lagann; 51 files +Fullmetal Alchemist - Brotherhood 85249 tvdb: 鋼の錬金術師 FULLMETAL ALCHEMIST (2009), alias "Full Metal Alchemist Brotherhood" — spaced, unlike this folder; 64 files +[NewbSubs] Attack on Titan~Shingeki no Kyojin Series (1080p Blu-ray x265 Dual Audio) 267440 tvdb: 進撃の巨人 (2013), aliases carry "Attack on Titan" and "Shingeki no Kyojin", joined here by a tilde; 27 files +Kimetsu no Yaiba 348545 the same record as the Anime Time pack above: two release folders, ONE show; 12 files +Rick and Morty The Anime 420279 tvdb: リック・アンド・モーティ:ジ・アニメ (2024), no English alias on the record; 10 files +[Anime Time] Dorohedoro (Season 1+OVA+Extra) [Dual Audio][BD][1080p][HEVC 10bit x265][AAC][Eng Sub] 370761 tvdb: ドロヘドロ (2020), alias Dorohedoro; 9 files +Ken Il Guerriero HOKUTO NO KEN 79156 tvdb: 北斗の拳 (1984), alias "Hokuto no Ken"; the Italian title matches nothing and 465216 is the 2026 remake; 5 files diff --git a/scripts/media-shows-tvdb.py b/scripts/media-shows-tvdb.py new file mode 100755 index 0000000..399457c --- /dev/null +++ b/scripts/media-shows-tvdb.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +r"""Ask tvdb what it calls the shows a plan renamed WITHOUT a tvdb id. + + media-shows-tvdb.py [--plan TSV] [--root DIR] [--park DIR] [--map TSV] + [--out TSV] [--credentials FILE] + +`media-shows.py` renames a series to tvdb's own words only when an id backs the +name; everything else keeps the metadata provider's words and no episode title, +and the plan's mkdir rows are where those folders show up. This script turns that +list into a decision sheet: one row per destination series folder that carries no +`[tvdbid-NNNN]`, with what tvdb's search answers for it and a verdict that says +HOW MUCH was guessed to get there. + +It changes nothing. The sheet is for the operator; the file that changes +behaviour is `scripts/media-shows-tvdb-ids.tsv`, whose rows he approves. That is +the loop: plan -> this sheet -> approved map rows -> re-plan. + +Verdicts, weakest last: + + EXACT tvdb's own name equals the folder's words as they stand. + PUNCT it equals them only after release junk is stripped (pack numbers, + codecs, group names, `Complete Series`); the difference is spelling, + not identity. + NEAR tvdb answered something whose name is NOT the query. The best hit is + printed so a human can judge it, and it is never applied by anything. + NONE nothing left to ask with, or no answer at all. + +Acceptance is deliberately unforgiving — a hit counts only when tvdb's name +equals the query with every non-alphanumeric dropped from both sides, and the +series' year still has to fall inside any year span the folder names. The +cleaner is the only thing that got smarter over the passes this tool replaces; +the acceptance test never moved, because a wrong id renames a whole pack to the +wrong show. A query that needs the cleaner is reported as PUNCT for exactly that +reason: the machine stripped words, so a human reads the row. + +Read-only against tvdb. The credential file is the planner's own +(`api_key=`, optional `pin=`), read here and never printed. +""" +import argparse +import collections +import importlib.util +import json +import os +import re +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + +ARGS = argparse.ArgumentParser(description=__doc__.splitlines()[0], + formatter_class=argparse.RawDescriptionHelpFormatter) +ARGS.add_argument("--plan", default="/tmp/tv-plan.tsv", + help="the plan media-shows.py just wrote (its mkdir rows are " + "the folders this sheet asks about)") +ARGS.add_argument("--root", default=os.environ.get("TV_ROOT", "/mnt/entropy/TVShows"), + help="the TV library the plan renames") +ARGS.add_argument("--park", default="", + help="the review pile inside it (its folders are not shows)") +ARGS.add_argument("--map", + default=os.path.join(os.path.dirname(os.path.abspath(__file__)), + "media-shows-tvdb-ids.tsv"), + help="the operator's approved map; its folders are reported as " + "MAPPED and never asked again") +ARGS.add_argument("--credentials", + default=os.path.expanduser("~/.config/secrets/tvdb"), + help="file with an api_key= line (and a pin= for a user-issued " + "key); mode 600, and never printed") +ARGS.add_argument("--out", default="/tmp/tvdb-proposal.tsv", + help="the decision sheet") +ARGS = ARGS.parse_args() + +ROOT = os.path.abspath(ARGS.root) +PARK = os.path.abspath(ARGS.park or os.path.join(ROOT, "_unsorted")) + +_spec = importlib.util.spec_from_file_location( + "eng", os.path.join(os.path.dirname(os.path.abspath(__file__)), + "media-titles.py")) +eng = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(eng) + +# Release metadata, not the show: everything a scene folder name says about the +# ENCODING, the packaging or the languages, which tvdb has never heard of. +JUNK = re.compile( + r"complete|series|dual|multi|audio|sub|subs|multisub|dualaudio|" + r"\d{3,4}p|1080p|720p|2160p|480p|4k|hd|hdtv|cam|hdcam|telesync|x264|x265|hevc|avc|" + r"\d+bit|bit|bluray|blu-?ray|bdrip|web-?rip|web-?dl|webrip|hdtvrip|" + r"dvdrip|dvdr|dvdscr|tsr|tsv|mkv|avi|mp4|aac|ac3|dts|eac3|flac|ddp?5[.1]1|" + r"bd|dvd|rip|boxset|box|season|seasons|episodes?|specials?|shorts|ost|" + r"ova|ovas|oad|repack|proper|internal|mi?xed|budgetbits|deluxe|remux|" + r"remastered|uncut|collector|edition|extras?|volume|volumes|part|" + r"disc|disk|cd|ntsc|pal|region|fixed|patched|proof|english|esp|" + r"espagnol|french|vostfr|subtitul\w*|spanish|german|italian|ita|eng|fre|spa|lat|" + r"upscaled?|ai|dd|movie|movies", + re.I) +GROUP = re.compile(r"KONTRAST|TGx|Judas|Silence|FreetheFilms|NTb|DEADPOOL|Xploit|" + r"SM737|TSA|3OISL3R|PLAYWEB|EPiC|FLUX|HONEY|Zaxton|Cca3rW|" + r"monikeres|frank|hdh|psi|rtfpemaker|cbm|web|asser|astro|" + r"yfn|uindex|www|org|com|net|mesc|ataraxiaprime|seph\d*|" + r"[a-z]+subs?|edge", re.I) +BRACKET = re.compile(r"[\[({]([^\])}]*)[\])}]") +NUM_RANGE = re.compile(r"(?<!\w)\d{1,4}\s*(?:-|–|to|through)\s*\d{1,4}(?!\w)", re.I) +INSIDE = re.compile(r"all|complete|full|dual|multi|audio|sub|subs|ova|ovas|" + r"movies?|episodes?|extras?|bonus|pack|remux|region\s*\d*|" + r"bd|dvd|cd|ntsc|pal|hr-?dvd", re.I) + + +def drop_bracket(m): + """A bracket says nothing when its words are junk; keep it when they aren't.""" + toks = [t for t in re.split(r"[\s_.,+~&]+", m.group(1)) if t] + if not toks or all(INSIDE.fullmatch(t) or JUNK.fullmatch(t) + or GROUP.fullmatch(t) + or re.fullmatch(r"\d+[a-z]?", t, re.I) + or re.fullmatch(r"(19|20)\d\d", t) for t in toks): + return " " + return " " + m.group(1) + " " + + +def clean(name): + """The folder's words with everything about the RELEASE taken out, so what is + left is what a person would type into tvdb. `Star Trek Deep Space 9 Deluxe + DVD Boxset + Extras DVDRip TSV` becomes `Star Trek Deep Space 9`; `SNL 16-20` + becomes `SNL`.""" + name = re.sub(r"^\s*www\b.*?\b(org|com|net)\b\s*[-–]\s*", " ", name, flags=re.I) + name = re.sub(r"\bS\d{1,2}([ .Ee]\d+)?[-–Ee\d.]*", " ", name) + name = re.sub(r"\b\d{1,2}[xX]\d{1,3}\b", " ", name) + name = NUM_RANGE.sub(" ", name) + name = re.sub(r"(19|20)\d\d(?:\s*[-/–]\s*(?:19|20)\d\d)?", " ", name) + name = re.sub(r"\bby\s+[A-Za-z0-9_.-]+$", " ", name) + name = BRACKET.sub(drop_bracket, name) + name = re.sub(r"[\[()\]:,;!?*]", " ", name) + toks = re.split(r"[\s_.\-+~&]+", name) + out = [] + for i, t in enumerate(toks): + if not t or JUNK.fullmatch(t) or GROUP.fullmatch(t): + continue + if re.fullmatch(r"\d+", t): + after = sum(1 for x in toks[i + 1:] if x + and not JUNK.fullmatch(x) and not GROUP.fullmatch(x) + and not re.fullmatch(r"\d+", x)) + # A pack index (`001`) and a lead-in number over a real title + # (`01 Charles Barkley`) are noise; `30 Rock`, `24`, `Deep Space 9` + # are the title itself. + if len(t) >= 3 or (not out and after >= 2): + continue + out.append(t) + while out and out[-1].lower() in ("and", "with", "the", "a", "an", "to", "of"): + out.pop() + return " ".join(out).strip() + + +def years(name): + return [int(y) for y in re.findall(r"\b(19\d\d|20\d\d)\b", name)] + + +def bare(s): + return re.sub(r"[^a-z0-9]", "", s.lower()) + + +CRED = {} +try: + for _line in open(ARGS.credentials): + if "=" in _line: + k, v = _line.strip().split("=", 1) + CRED[k.strip()] = v.strip() +except OSError: + print(f"tvdb: no credentials at {ARGS.credentials} — the sheet would be all " + "NEAR and NONE, which is not a decision sheet", file=sys.stderr) + sys.exit(2) +if not CRED.get("api_key"): + print(f"tvdb: no api_key in {ARGS.credentials}", file=sys.stderr) + sys.exit(2) + +TOKEN = {"jwt": ""} + + +def call(path): + """One tvdb v4 GET, logging in again once when the JWT expires mid-run.""" + for attempt in (1, 2): + if not TOKEN["jwt"]: + body = {"apiKey": CRED["api_key"]} + if CRED.get("pin"): + body["token"] = CRED["pin"] + req = urllib.request.Request("https://api4.thetvdb.com/v4/login", + data=json.dumps(body).encode(), + headers={"Content-Type": + "application/json"}) + TOKEN["jwt"] = json.load( + urllib.request.urlopen(req, timeout=30))["data"]["token"] + try: + req = urllib.request.Request("https://api4.thetvdb.com/v4" + path, + headers={"Authorization": + "Bearer " + TOKEN["jwt"]}) + return json.load(urllib.request.urlopen(req, timeout=30))["data"] + except urllib.error.HTTPError as e: + if e.code == 401 and attempt == 1: + TOKEN["jwt"] = "" + continue + raise + + +MAPPED = {} +if ARGS.map and os.path.exists(ARGS.map): + for _line in open(ARGS.map): + _line = _line.rstrip("\n") + if not _line.strip() or _line.startswith("#"): + continue + cols = _line.split("\t") + if len(cols) > 1 and cols[1].strip().isdigit(): + MAPPED[cols[0].strip()] = cols[1].strip() + +# The folders the plan created inside the library, with the file count each one +# will hold. The review pile is not a show: its files belong to no identity, and +# naming the pile would put one junk series on the sheet for all of them. +shows = collections.Counter() +for line in open(ARGS.plan).read().splitlines()[1:]: + parts = line.split("\t") + if len(parts) < 4 or parts[0] != "mkdir": + continue + dst = os.path.abspath(parts[2]) + if not dst.startswith(ROOT + os.sep) or dst.startswith(PARK + os.sep): + continue + top = os.path.relpath(dst, ROOT).split(os.sep)[0] + if top.startswith((".", "_")): + continue + if "[tvdbid-" in top: + continue # an id already backs it + shows.setdefault(top, 0) +for line in open(ARGS.plan).read().splitlines()[1:]: + parts = line.split("\t") + if len(parts) < 4 or parts[0] != "move": + continue + dst = os.path.abspath(parts[2]) + if not dst.startswith(ROOT + os.sep) or dst.startswith(PARK + os.sep): + continue + top = os.path.relpath(dst, ROOT).split(os.sep)[0] + if "[tvdbid-" in top: + continue + shows[top] += 1 + +tally = collections.Counter() +rows = [] +for folder in sorted(shows, key=lambda f: (-shows[f], f)): + files, tag, note = shows[folder], "NONE", "" + if folder in MAPPED: + rows.append([files, "MAPPED", folder, "", "", "", MAPPED[folder], + "the operator's map already states this id"]) + tally["MAPPED"] += 1 + continue + query, untouched = clean(folder), clean(folder) == folder + ys = years(folder) + if not query: + rows.append([files, "NONE", folder, "", "", "", "", + "nothing is left of the folder's name once release junk" + " is gone"]) + else: + try: + hits = call("/search?query=" + urllib.parse.quote(query) + + "&type=series") or [] + except Exception as e: # noqa: BLE001 + hits = [] + note = f"tvdb did not answer: {e}" + want = bare(query) + pick = None + for h in hits: + if bare(h.get("name") or "") != want: + continue + hy = str(h.get("year") or "") + if ys and hy.isdigit() and not (min(ys) <= int(hy) <= max(ys)): + continue + pick = h + break + if pick: + tag = "EXACT" if untouched else "PUNCT" + rows.append([files, tag, folder, query, pick.get("name", ""), + str(pick.get("year", "") or ""), + str(pick.get("tvdb_id", "") or ""), ""]) + elif hits: + best = hits[0] + rows.append([files, "NEAR", folder, query, best.get("name", ""), + str(best.get("year", "") or ""), + str(best.get("tvdb_id", "") or ""), + f"{len(hits)} answer(s); tvdb's own name is not the " + "query, so nothing would take it"]) + else: + rows.append([files, "NONE", folder, query, "", "", "", + note or "tvdb has no series by that name"]) + tally[rows[-1][1]] += 1 + time.sleep(0.12) + +# Two folders mapping to one record is not two shows; it is one show the release +# folders split. Say it on every row involved, or the operator approves the same +# id twice and reads the second as a mistake. +by_id = collections.Counter(r[6] for r in rows if r[6] and r[1] != "MAPPED") +for r in rows: + if r[6] and r[1] != "MAPPED" and by_id[r[6]] > 1: + r[7] = (f"MERGE: {by_id[r[6]]} folders map to tvdb {r[6]} — one show, " + "the releases split it; " + r[7]).strip("; ") + +with open(ARGS.out, "w") as fh: + fh.write("files\tverdict\tfolder\tquery\ttvdb_name\ttvdb_year\ttvdb_id\tnote\n") + for r in rows: + fh.write("\t".join(str(c) for c in r) + "\n") +print(f"sheet: {len(rows)} folders" + + "".join(f" {t}={n}" for t, n in sorted(tally.items())) + + f" -> {ARGS.out}") diff --git a/scripts/media-shows.py b/scripts/media-shows.py index 4dff323..ebacd3d 100755 --- a/scripts/media-shows.py +++ b/scripts/media-shows.py @@ -3,7 +3,7 @@ r"""Plan a Jellyfin-format relayout of a TV library. DRY RUN ONLY: nothing moves media-shows.py [--root DIR] [--enum TSV] [--titles TSV] [--probed TSV] [--tmdb-cache JSON] [--tmdb] [--movies DIR] [--park DIR] - [--out TSV] [--exceptions TXT] + [--tvdb-ids TSV] [--out TSV] [--exceptions TXT] Inputs are the TSVs scripts/media-census.py exports: `enumerate` lists what exists, `jellyfin` says which episode each file IS (its series' TMDb and Tvdb @@ -24,6 +24,14 @@ The series name and the episode title are the ones the IDENTITY authority — tv — states for the id the metadata server matched, not the words a release folder or a server's folder-derived Series name happens to carry. +A folder tvdb's search cannot state is not a folder tvdb does not know: the +record exists, it is titled in the show's own language, and the English name +rides in aliases the search never reads. --tvdb-ids is the human door for those: +one line per pack folder, `folder <TAB> tvdb id <TAB> note`, every id checked by +hand against the record it names. It is an input file like the census, so a plan +stays reproducible, and every folder it touched is listed in the exceptions — +one wrong row is a whole pack renamed to the wrong show. + PLAN is the TSV scripts/media-apply.py replays: `action src dst kind`, every mkdir row before any move, and `keep` for a file already where the contract puts it. EXCEPTIONS is the review list: everything parked, and why. @@ -106,6 +114,16 @@ ARGS.add_argument("--tvdb-credentials", default=os.path.expanduser("~/.config/secrets/tvdb"), help="file with an api_key= line (and a pin= line for a " "user-issued key); mode 600, and never the cache") +ARGS.add_argument("--tvdb-ids", + default=os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "media-shows-tvdb-ids.tsv"), + help="the operator's map: folder <TAB> tvdb id <TAB> note, for the\n" + "packs whose name tvdb's search cannot state (anime is titled\n" + "by its own record, the English name rides in aliases). Used\n" + "only when the file exists; a line without a numeric id stops\n" + "the plan, because a typo renames a whole pack to the wrong\n" + "show.") ARGS.add_argument("--movies", default="/mnt/entropy/Movies", help="movie library a mis-filed film moves into") ARGS.add_argument("--park", default="", @@ -611,6 +629,63 @@ def extras_kind(path, extra_type): return "extras" +# --------------------------------------------------------- the operator's map +# The map is the human answer to a name the authority cannot be searched by. It +# is read as an input file — like the census and the cache — so a plan stays +# reproducible from the files beside it, and it outranks a provider id: a box +# set's provider id is the server's own guess at a folder name, while a map row +# is an id someone checked against the record it names. +MAP = {} # top-level pack folder -> {"id", "note"} +MAP_SEEN, MAP_CLASH = set(), [] # what the map actually touched +if ARGS.tvdb_ids and os.path.exists(ARGS.tvdb_ids): + for _n, _line in enumerate(open(ARGS.tvdb_ids), 1): + _line = _line.rstrip("\n") + if not _line.strip() or _line.startswith("#"): + continue + cols = _line.split("\t") + folder = cols[0].strip() + sid = cols[1].strip() if len(cols) > 1 else "" + if not folder or not sid.isdigit(): + print(f"{ARGS.tvdb_ids}:{_n}: not a numeric tvdb id: {_line}", + file=sys.stderr) + sys.exit(2) + if folder in MAP: + print(f"{ARGS.tvdb_ids}:{_n}: folder named twice: {folder}", + file=sys.stderr) + sys.exit(2) + MAP[folder] = {"id": sid, + "note": cols[2].strip() if len(cols) > 2 else ""} + + +def top_folder(path): + """The library folder a file's pack sits in. An id belongs to a show, and a + show is a folder here, so that is what the map keys on.""" + return os.path.relpath(path, ROOT).split(os.sep)[0] + + +def map_id_for(path, provider_id): + """The id the operator states for this file's folder. The map wins over the + provider's — it is the only door for a name tvdb cannot state — and a + disagreement between the two is written where the operator reads it, never + resolved in silence.""" + if not MAP: + return provider_id + top = top_folder(path) + entry = MAP.get(top) + if not entry: + return provider_id + MAP_SEEN.add(top) + if provider_id and provider_id != entry["id"]: + MAP_CLASH.append((top, provider_id, entry["id"])) + return entry["id"] + + +def map_note(): + if not MAP: + return "" + return f" (+{len(MAP_SEEN)} folders from the operator's map)" + + # ----------------------------------------------------------- classify every file series = {} # norm(name) -> {name, year, tmdb} claimants = [] # (row, name, season, [episodes]) @@ -644,7 +719,7 @@ def as_extra(r, owner, extra_type): if not owner: unknown.append((r, "bonus material with no series to own it")) return - note(owner) + note(owner, tvdb_id=map_id_for(r["path"], "")) extras.append((r, owner, extras_kind(r["path"], extra_type))) for r in video: @@ -686,12 +761,12 @@ for r in video: eps = [ep] claimants.append((r, owner, season, eps)) note(owner, t["year"] if t else "", t["tmdb"] if t else "", - t["tvdb"] if t else "") + map_id_for(r["path"], t["tvdb"] if t else "")) continue owner = series_of_dir(os.path.dirname(p)) if fs is not None and owner and leading_index(r["base"]): claimants.append((r, owner, fs, [leading_index(r["base"])])) - note(owner) + note(owner, tvdb_id=map_id_for(r["path"], "")) continue if eng.is_extra(p, title_of(r["base"])[0], dur.get(p, 0.0), ROOT): as_extra(r, owner, "") @@ -1073,7 +1148,7 @@ def write_plan(): sized = sum(held[s]["bytes"] for s, _d, _k in moves if s in held) print(f"plan: {len(moves) - len(parked)} moves, {len(parked)} parked" f" ({sized / 2**40:.2f} TiB touched), {len(keeps)} already placed," - f" {len(dirs)} folders{provider_note()} -> {ARGS.out}") + f" {len(dirs)} folders{map_note()}{provider_note()} -> {ARGS.out}") def write_exceptions(): @@ -1123,6 +1198,36 @@ def write_exceptions(): fh.write(" " + "; ".join( f"{n} S{'/'.join(f'{x:02d}' for x in ss)}" for n, ss in clash[:300]) + "\n") + map_ids = {e["id"] for e in MAP.values()} + unseen = sorted(f for f in MAP if f not in MAP_SEEN) + unanswered = sorted({f"{identity(s)[0]} ({s['tvdb']})" + for s in canon.values() + if s.get("tvdb") in map_ids and not s.get("canon")}) + if MAP: + fh.write(f"\n{len(MAP_SEEN):>6} folders took their tvdb id from the\n") + fh.write(" operator's map — the human answer to a name tvdb's search\n" + " cannot state. A wrong row renames a whole pack to the wrong\n" + " show, so every folder it touched is listed here; fix the map\n" + " and re-plan, because a rename is idempotent.\n") + fh.write(" " + "; ".join( + f"{f} -> {MAP[f]['id']}" for f in sorted(MAP_SEEN)) + "\n") + if unseen: + fh.write(f"\n{len(unseen):>6} map rows name no folder on the array: the\n") + fh.write(" pack moved, or the row carries a name the array never held.\n") + fh.write(" " + "; ".join(unseen[:300]) + "\n") + if MAP_CLASH: + fh.write(f"\n{len(MAP_CLASH):>6} folders carry a provider tvdb id the map\n") + fh.write(" contradicts. The map won: a box set's provider id is the\n") + fh.write(" server's guess at a folder name. Read each one:\n") + fh.write(" " + "; ".join( + f"{f} provider {p} map {m}" + for f, p, m in sorted(MAP_CLASH)[:300]) + "\n") + if unanswered: + fh.write(f"\n{len(unanswered):>6} map ids tvdb never answered, so those\n") + fh.write(" folders carry the id and the provider's own name; both\n") + fh.write(" matchers resolve by the id in the folder tag, so the next\n" + " re-plan takes tvdb's title. Nothing was invented.\n") + fh.write(" " + "; ".join(unanswered[:300]) + "\n") print(f"exceptions -> {ARGS.exceptions}") diff --git a/serverAppConfigs-docs/Dankserver/Media Relayout.md b/serverAppConfigs-docs/Dankserver/Media Relayout.md index a1ad5f1..568a3a0 100644 --- a/serverAppConfigs-docs/Dankserver/Media Relayout.md +++ b/serverAppConfigs-docs/Dankserver/Media Relayout.md @@ -301,17 +301,35 @@ visible series folder that real files get renamed into. `--park` outside the roo makes the next plan re-file the pile instead of skipping it. Inside a hidden top-level folder a file's own words may MATCH a registered show, never invent one. -Release-group folder names are the one thing no provider can read, so the -pending TVShows naming plan files 179 top-level directories (9,931 files) under -the junk name they arrived with. A TVDB id map can name them, under the ladder's -own acceptance rule: a hit counts only when TVDB's spelling equals the cleaned -query, or differs from it by punctuation alone — the same letters are not a -guess. Everything else is a choice for Frank, and the map is never applied -unreviewed: `/mnt/entropy/media-relayout-shows-tvdb-proposal.tsv` carries one -row per folder with `verdict` = `EXACT` / `PUNCT` / `NEAR` / `NONE` and a `note` -naming the two traps — a folder marked `(US)` that matches the UK original, and -several folders mapping to one id, which is a MERGE whose duplicate episodes the -executor will refuse until a keeper is chosen. +**The operator's id map is the door for a name tvdb cannot be searched by.** +`scripts/media-shows.py` reads `scripts/media-shows-tvdb-ids.tsv` by default: one +line per pack folder, `folder <TAB> tvdb id <TAB> note`. The file exists because +tvdb titles a record in the show's own language and the English name rides in +*aliases* the name search never reads: `ブラッククローバー` (331753) answers to no +spelling of "Black Clover", and the bare alias `One Piece` belongs to 392276, the +2023 live action, not to the 1999 record (81797) the pack here is. The map +outranks a provider id — a box set's provider id is the server's own guess at a +folder name — and it merges the release folders that are one show. Everything it +touched is written to the exceptions: every folder it named, every row that +matched no folder, every disagreement with a provider id, every id tvdb never +answered. A line whose id is not a number stops the plan, because a typo renames +a whole pack to the wrong show. Only folders the search CANNOT reach belong in +it; a row for a folder the search would resolve hides a live lookup behind a +number nobody re-checks. + +`scripts/media-shows-tvdb.py` makes the sheet the map is built from. It reads a +plan's `mkdir` rows, asks tvdb about every destination folder carrying no +`[tvdbid-`, and writes one row per folder with the verdict that says how much was +guessed to get there: `EXACT` (tvdb's own spelling), `PUNCT` (equal only after +release junk is stripped from the folder's words), `NEAR` (tvdb answered +something else — the best hit is printed for a human and nothing applies it), +`NONE`. Acceptance never moved across the passes that built this: a hit counts +only when tvdb's name equals the query with every non-alphanumeric dropped from +both sides, and the record's year still has to fall inside any year span the +folder names. That gate is what caught three confident picks — Fairy Tail's 2024 +sequel (410031), the 2026 Hokuto no Ken remake (465216) and `Mugyutto! Black +Clover` (378762) offered for Black Clover. It is why the map is approved by hand +and the sheet is never fed to the planner. ## The music and audiobook arms diff --git a/tests/media_shows.bats b/tests/media_shows.bats index e78bf1d..7b1bbd2 100755 --- a/tests/media_shows.bats +++ b/tests/media_shows.bats @@ -566,3 +566,132 @@ PY run grep -i "numbering tvdb contradicts" "$EXC" [ "$status" -ne 0 ] } + +# ------------------------------------------------------------- the operator map +# Most of this library carries no tvdb id anywhere: the authority titles anime by +# its own record — in the show's language — and the English name rides in aliases +# the planner's name search never reads, so a search that demands tvdb's own +# spelling cannot reach it. The operator's map is the other door: one line per +# folder, `folder <TAB> tvdb id <TAB> note`, each id checked by hand against the +# record it names. It outranks a provider id, it merges the release folders that +# are one show, and every folder it touched is named in the exceptions: one wrong +# row there is 1,268 renames to the wrong show, so nothing about it is silent. + +plan_map() { # the plan, with the operator's map in hand + run python3 "$PLAN" --root "$LIB" --enum "$ENUM" --titles "$TITLES" \ + --probed "$PROBED" --tmdb-cache "$CACHE" --movies "$MOVIES" \ + --tvdb-ids "$1" --out "$OUT" --exceptions "$EXC" +} + +@test "a folder the operator mapped to a tvdb id renames to that record" { + printf '{"tvdb:series:331753": %s}\n' \ + '{"name": "Black Clover", "year": "2017"}' > "$CACHE" + { + printf '# the note column carries why the name search cannot reach it\n' + printf 'Black Clover 001\t331753\tthe record holds no alias\n' + } > "$BATS_TEST_TMPDIR/ids.tsv" + file "$LIB/Black Clover 001/Season 01/Black.Clover.001.mkv" 5000 + title_row "$LIB/Black Clover 001/Season 01/Black.Clover.001.mkv" \ + "Black Clover 001" "" 1 1 + plan_map "$BATS_TEST_TMPDIR/ids.tsv" + want="$LIB/Black Clover (2017) [tvdbid-331753]/Season 01" + want+="/Black Clover (2017) - S01E01.mkv" + [ "$(dst "$LIB/Black Clover 001/Season 01/Black.Clover.001.mkv")" = "$want" ] +} + +@test "release folders one id maps are ONE series folder" { + printf '{"tvdb:series:81797": %s}\n' \ + '{"name": "One Piece", "year": "1999"}' > "$CACHE" + { + printf '[Anime Time] One Piece (0001-1071)\t81797\tbare "One Piece" is the 2023 live action\n' + printf 'One Piece Ship Collection\t81797\tthe same record, split by release\n' + } > "$BATS_TEST_TMPDIR/ids.tsv" + file "$LIB/[Anime Time] One Piece (0001-1071)/One.Piece.0001.mkv" 5000 + file "$LIB/One Piece Ship Collection/One.Piece.0002.mkv" 6000 + title_row "$LIB/[Anime Time] One Piece (0001-1071)/One.Piece.0001.mkv" \ + "One Piece" 1999 1 1 + title_row "$LIB/One Piece Ship Collection/One.Piece.0002.mkv" \ + "One Piece Ship Collection" "" 1 2 + plan_map "$BATS_TEST_TMPDIR/ids.tsv" + want="$LIB/One Piece (1999) [tvdbid-81797]/Season 01" + want+="/One Piece (1999) - S01E01.mkv" + [ "$(dst "$LIB/[Anime Time] One Piece (0001-1071)/One.Piece.0001.mkv")" \ + = "$want" ] + want="$LIB/One Piece (1999) [tvdbid-81797]/Season 01" + want+="/One Piece (1999) - S01E02.mkv" + [ "$(dst "$LIB/One Piece Ship Collection/One.Piece.0002.mkv")" = "$want" ] +} + +@test "the map outranks a provider id and says which two disagreed" { + printf '{"tvdb:series:4242": %s, "tvdb:series:78850": %s}\n' \ + '{"name": "Real Ship", "year": "1999"}' \ + '{"name": "Box Junk", "year": "2016"}' > "$CACHE" + printf '%s\t4242\thand-verified against the record\n' \ + "Show Box (2016) Season 1 S01 (1080p WEB-DL)" > "$BATS_TEST_TMPDIR/ids.tsv" + plan_map "$BATS_TEST_TMPDIR/ids.tsv" + dst "$BOX/Show.Box.S01E01.1080p.WEB-DL.mkv" \ + | grep -q "^$LIB/Real Ship (1999) \[tvdbid-4242\]/Season 01/" + run grep -E '78850.*4242|4242.*78850' "$EXC" + [ "$status" -eq 0 ] +} + +@test "a map id the authority never answered keeps the provider's name and is named" { + printf 'Black Clover 001\t331753\ttvdb never answered\n' \ + > "$BATS_TEST_TMPDIR/ids.tsv" + file "$LIB/Black Clover 001/Season 01/Black.Clover.001.mkv" 5000 + title_row "$LIB/Black Clover 001/Season 01/Black.Clover.001.mkv" \ + "Black Clover 001" "" 1 1 + plan_map "$BATS_TEST_TMPDIR/ids.tsv" + dst "$LIB/Black Clover 001/Season 01/Black.Clover.001.mkv" \ + | grep -q "^$LIB/Black Clover 001 \[tvdbid-331753\]/Season 01/" + run grep -q "331753" "$EXC" + [ "$status" -eq 0 ] +} + +@test "a map row for a folder the array no longer holds is reported" { + printf 'Gone Pack\t4242\tmoved away\n' > "$BATS_TEST_TMPDIR/ids.tsv" + plan_map "$BATS_TEST_TMPDIR/ids.tsv" + run grep "Gone Pack" "$EXC" + [ "$status" -eq 0 ] +} + +@test "a map line whose id is not a number stops the plan" { + printf 'Black Clover 001\tnotanid\ttypo\n' > "$BATS_TEST_TMPDIR/ids.tsv" + file "$LIB/Black Clover 001/Season 01/Black.Clover.001.mkv" 5000 + title_row "$LIB/Black Clover 001/Season 01/Black.Clover.001.mkv" \ + "Black Clover 001" "" 1 1 + plan_map "$BATS_TEST_TMPDIR/ids.tsv" + [ "$status" -ne 0 ] + [[ "$output" == *"notanid"* ]] +} + +# The map the fleet ships is scripts/media-shows-tvdb-ids.tsv, and the planner +# reads it by default: an unusable line there stops every plan the operator runs +# from the checkout, so the seam validates the real file, not a fixture copy. +@test "the shipped operator map parses and never stops a plan" { + MAPS="$REPO_ROOT/scripts/media-shows-tvdb-ids.tsv" + run awk -F '\t' '!/^#/ && NF { + if (NF < 2 || $2 !~ /^[0-9]+$/) { print "unusable row: " $0; bad = 1 } + } END { exit bad }' "$MAPS" + [ "$status" -eq 0 ] + dup=$(awk -F '\t' '!/^#/ && NF {print $1}' "$MAPS" | sort | uniq -d) + [ -z "$dup" ] + plan_map "$MAPS" + [ "$status" -eq 0 ] + grep -qi "operator's map" "$EXC" +} + +@test "a map that names one folder twice stops the plan" { + printf '{"tvdb:series:78850": %s}\n' \ + '{"name": "Naruto", "year": "2002"}' > "$CACHE" + { + printf 'Naruto Complete Series\t78850\tthe dub pack\n' + printf 'Naruto Complete Series\t331753\ta paste of another row\n' + } > "$BATS_TEST_TMPDIR/ids.tsv" + file "$LIB/Naruto Complete Series/Season 01/naruto 01.mkv" 5000 + title_row "$LIB/Naruto Complete Series/Season 01/naruto 01.mkv" \ + "Naruto Complete Series" "" 1 1 + plan_map "$BATS_TEST_TMPDIR/ids.tsv" + [ "$status" -eq 2 ] + [[ "$output" == *"named twice"* ]] +} From 2625ad10836b428d24b74dcda9235e3e48ef7b9d Mon Sep 17 00:00:00 2001 From: Frank <frank@dankware> Date: Sat, 31 Oct 2026 14:49:12 -0700 Subject: [PATCH 11/18] fix(media): a number too long to be an episode is not one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Attack on Titan box set on the array names two files S03E2019 and S04E47370355, and the provider rows the metadata server recorded for them carry the same junk. EP_RANGE capped its first episode group at three digits without guarding the run, so the pass-2 plan filed both under E201 and E473 — slots that belong to real episodes — inside 進撃の巨人 (2013) [tvdbid-267440]. - guard the first episode group the way the second was already guarded, so a longer digit run states no marker at all; a glued resolution stays legal, because S01E01-1080p is still ONE episode - cap a provider row's episode at EP_MAX: such a row is renumbered from tvdb's title when the series' id answers, and parks with a reason that names the junk when nothing answers - split the operator-map exceptions into the two things they are: a folder that IS on the array whose files never became a series (bonus material with no owner: Fairy Tail, Kimetsu no Yaiba, Dorohedoro) is no longer reported as a folder the array no longer holds, and the missing-folder hint says the review pile counts as moved because the map reads top-level folders only - the vault's evidence list carries the law; the four tests were read red against the shipping planner before any of it changed Replanning the two real files now parks them under their own names with the reasons "a provider row of season 3 episode 2019" and "… season 4 episode 47370355". --- scripts/media-shows.py | 43 +++++++++++---- .../Dankserver/Media Relayout.md | 5 +- tests/media_shows.bats | 54 +++++++++++++++++++ 3 files changed, 91 insertions(+), 11 deletions(-) diff --git a/scripts/media-shows.py b/scripts/media-shows.py index ebacd3d..860a945 100755 --- a/scripts/media-shows.py +++ b/scripts/media-shows.py @@ -168,12 +168,16 @@ SEASON_META = re.compile(r"^season\.nfo$", re.I) FOLDER_IMAGE = re.compile(r"^folder\.(jpg|jpeg|png|webp|bmp)$", re.I) SEASON_DIR_RE = re.compile(r"(?i)^\s*(?:season[\s._-]*(\d{1,3})|s(\d{1,2}))\s*$") BAD_CHARS = re.compile(r'[<>:"/|?*]') -# SxxEyy with an optional second episode, plus the 1x04 form. The second number -# is guarded so a resolution never reads as an episode: "S01E01-1080p" is ONE -# episode, and the longest-match backtracking of \d{1,3} is what the (?![\dpP]) -# after it closes off. +# SxxEyy with an optional second episode, plus the 1x04 form. Both episode numbers +# are guarded so a longer digit run never reads as an episode. A resolution glued +# on stays legal — "S01E01-1080p" is ONE episode — while "S03E2019" and +# "S04E47370355" (both real names on the array, from a box set whose provider rows +# say the same junk) state no episode at all: the run is an airdate or a pack +# index, and the first three digits of it would steal E201 and E473 from the +# episodes that own them. No marker means the file is numbered by its provider +# row, or by tvdb's title, or it parks. EP_RANGE = re.compile(r"(?i)(?<![a-z0-9])s(?:eason[\s._-]*)?(\d{1,2})[\s._-]*" - r"e(?:p(?:isode)[\s._-]*)?(\d{1,3})" + r"e(?:p(?:isode)[\s._-]*)?(\d{1,3})(?![\dpP])" r"(?:[\s._-]*(?:-|to[\s._-]*|ep)[\s._-]*(?:s\d{1,2}[\s._-]*)?e?" r"(\d{1,3})(?![\dpP]))?") NX_RE = re.compile(r"(?i)(?<![a-z0-9])(\d{1,2})x(\d{2,3})(?![a-z0-9])") @@ -201,6 +205,11 @@ EXTRA_KIND = {"trailer": "trailers", "tmbd": "trailers", "intro": "other", "deleted": "deleted scenes", "other": "other", "epilogue": "other", "opening": "other", "featurette": "featurettes", "short": "shorts"} SHORT_MAX = 45 * 60 # over this, a file with no episode evidence is film-shaped +# An episode number above this is not an episode number: the array's box sets +# carry 2019 (an airdate fragment) and 47370355 (a pack index) in the column the +# metadata server recorded as the match. tvdb's episode list is the only door on +# such a row; nothing files at E1000 or beyond. +EP_MAX = 999 def rel(p): @@ -748,10 +757,11 @@ for r in video: else: season = fs if fs is not None else num(t["season"]) ep = leading_index(r["base"]) or num(t["episode"]) - if not 0 <= season <= 200 or ep <= 0: + if not 0 <= season <= 200 or not 1 <= ep <= EP_MAX: # Season 0 IS a season: specials live in Season 00 by Jellyfin's own - # contract. A season that is a YEAR is the date matcher's doing, and - # the only authority on it is tvdb's episode list for the series. + # contract. A season that is a YEAR is the date matcher's doing and an + # episode above EP_MAX is a box set's airdate or pack index; neither is + # a number, and the only authority on either is tvdb's episode list. recovered = tvdb_number(t) if not recovered: unknown.append( @@ -1199,7 +1209,9 @@ def write_exceptions(): f"{n} S{'/'.join(f'{x:02d}' for x in ss)}" for n, ss in clash[:300]) + "\n") map_ids = {e["id"] for e in MAP.values()} - unseen = sorted(f for f in MAP if f not in MAP_SEEN) + on_array = {rel(p).split("/")[0] for p in held} + stalled = sorted(f for f in MAP if f not in MAP_SEEN and f in on_array) + unseen = sorted(f for f in MAP if f not in MAP_SEEN and f not in on_array) unanswered = sorted({f"{identity(s)[0]} ({s['tvdb']})" for s in canon.values() if s.get("tvdb") in map_ids and not s.get("canon")}) @@ -1211,9 +1223,20 @@ def write_exceptions(): " and re-plan, because a rename is idempotent.\n") fh.write(" " + "; ".join( f"{f} -> {MAP[f]['id']}" for f in sorted(MAP_SEEN)) + "\n") + if stalled: + fh.write(f"\n{len(stalled):>6} map rows name a folder on the array that\n") + fh.write(" never became a series: every file in it parked, so the id\n" + " had nothing to name. The map names a RECORD — it numbers\n" + " no episodes and owns no bonus material, so read the park\n" + " reasons above this line: a folder of openings, endings and\n" + " promos with no show to claim them is the usual shape.\n") + fh.write(" " + "; ".join( + f"{f} -> {MAP[f]['id']}" for f in stalled[:300]) + "\n") if unseen: fh.write(f"\n{len(unseen):>6} map rows name no folder on the array: the\n") - fh.write(" pack moved, or the row carries a name the array never held.\n") + fh.write(" pack moved (into the review pile counts: the map reads\n" + " top-level folders only), or the row carries a name the\n" + " array never held.\n") fh.write(" " + "; ".join(unseen[:300]) + "\n") if MAP_CLASH: fh.write(f"\n{len(MAP_CLASH):>6} folders carry a provider tvdb id the map\n") diff --git a/serverAppConfigs-docs/Dankserver/Media Relayout.md b/serverAppConfigs-docs/Dankserver/Media Relayout.md index 568a3a0..d361960 100644 --- a/serverAppConfigs-docs/Dankserver/Media Relayout.md +++ b/serverAppConfigs-docs/Dankserver/Media Relayout.md @@ -228,7 +228,10 @@ scripts/media-apply.py apply --plan /mnt/entropy/media-relayout-shows2-plan.tsv Evidence, strongest first — the order the planner believes things: -- the **episode marker the file's own name states** (`S01E02-E03`, `1x04`); +- the **episode marker the file's own name states** (`S01E02-E03`, `1x04`); a digit + run longer than three at that position is NOT a number — `S03E2019` and + `S04E47370355` are one box set's airdate fragment and pack index — so such a file + is renumbered by tvdb's title or parks, and is never truncated down to fit; - the **`Season NN` folder it sits in and the number its name leads with inside it**: `Season 19/01 Charles Barkley, Nirvana.avi` is S19E01; - the **provider row**: which episode the file IS, and the numbering of a file diff --git a/tests/media_shows.bats b/tests/media_shows.bats index 7b1bbd2..e77f07d 100755 --- a/tests/media_shows.bats +++ b/tests/media_shows.bats @@ -513,6 +513,46 @@ PY [[ "$output" == *"Best Of The Examples.mkv"* ]] } + +# A release pack can carry a number that is not an episode at all: two files of +# the Attack on Titan box set on the array are named S03E2019 and S04E47370355, +# and the provider rows the server recorded for them say the same junk. Reading +# either as "the first three digits" files the box set under E201 and E473 and +# steals those episode slots from their real owners, so a run too long to be an +# episode is no marker and no number: the file is renumbered from tvdb's title +# when the series' id answers it, and parks when nothing answers. +@test "an episode number longer than three digits never files as an episode" { + file "$LIB/Junk Numbers/Attack Pack S03E2019.mkv" 5000 + title_row "$LIB/Junk Numbers/Attack Pack S03E2019.mkv" "Attack Pack" 2013 3 2019 + plan_park + run grep -c -- "- S03E201 -" "$OUT" + [ "$output" = "0" ] + [[ "$(dst "$LIB/Junk Numbers/Attack Pack S03E2019.mkv")" == "$OUTSIDE/"* ]] + grep -q "a provider row of season 3 episode 2019" "$EXC" +} + +@test "a provider episode too long to be one is renumbered by the title tvdb states" { + printf '{"tvdb:series:4242": %s, "tvdb:episodes:4242": %s}\n' \ + '{"name": "Attack Pack", "year": "2013"}' \ + '{"3/7": "A Title tvdb Holds"}' > "$CACHE" + file "$LIB/Junk Numbers/Attack Pack S03E2019.mkv" 5000 + title_row "$LIB/Junk Numbers/Attack Pack S03E2019.mkv" \ + "Attack Pack" 2013 3 2019 "" "" "A Title tvdb Holds" "4242" + plan + want="$LIB/Attack Pack (2013) [tvdbid-4242]/Season 03" + want+="/Attack Pack (2013) - S03E07 - A Title tvdb Holds.mkv" + [ "$(dst "$LIB/Junk Numbers/Attack Pack S03E2019.mkv")" = "$want" ] +} + +@test "a resolution glued to the marker still reads as one episode" { + file "$LIB/Junk Numbers/Glued Mark S01E02-1080p.mkv" 5000 + title_row "$LIB/Junk Numbers/Glued Mark S01E02-1080p.mkv" \ + "The Examples" 2010 1 2 "" "" "Second Look" + plan + [ "$(dst "$LIB/Junk Numbers/Glued Mark S01E02-1080p.mkv")" \ + = "$LIB/The Examples (2010)/Season 01/The Examples (2010) - S01E02 - Second Look.mkv" ] +} + # Anime numbering is the one place a tvdb title can be confidently wrong: a pack # numbered by its release group still names S05E01, and tvdb answers that slot with # a different episode. A season the authority does not hold, or holds fewer of than @@ -655,6 +695,20 @@ plan_map() { # the plan, with the operator's map in han [ "$status" -eq 0 ] } +@test "a map row whose folder is on the array but never became a series says so" { + # The array holds three folders exactly like this one: a credits/extras pack + # the operator mapped to a record, whose every file is bonus material with no + # series to own it. The folder IS on the array, and telling the operator the + # pack moved sends them hunting for a folder that never left. + printf 'Credit Pack\t114801\tthe record tvdb names\n' > "$BATS_TEST_TMPDIR/ids.tsv" + file "$LIB/Credit Pack/01. Extras/NCOP 01.mkv" 1200 + plan_map "$BATS_TEST_TMPDIR/ids.tsv" + grep -q "bonus material with no series to own it" "$EXC" + grep -q "never became a series" "$EXC" + run grep "no folder on the array" "$EXC" + [ "$status" -ne 0 ] +} + @test "a map line whose id is not a number stops the plan" { printf 'Black Clover 001\tnotanid\ttypo\n' > "$BATS_TEST_TMPDIR/ids.tsv" file "$LIB/Black Clover 001/Season 01/Black.Clover.001.mkv" 5000 From f7083f175dbac3688f98d5dd859ea06e1b4a5f8b Mon Sep 17 00:00:00 2001 From: dank <dank@franksdank.cloud> Date: Sat, 31 Oct 2026 18:04:37 -0700 Subject: [PATCH 12/18] perf(media): the apply fsyncs the journal once per batch, not per move The 58k-rename apply on the spinning array spent most of its wall time in fsyncs: one durability flush per rename, and a quadratic `Journal.already()` scan over a growing record list on top of it. Both are the journal's, not the renames'. - `Journal` indexes move intents by (src, dst) so `already()` is O(1). - `--batch N` (default 64) writes N intents, fsyncs once, then performs those N renames. The invariant is unchanged: no rename runs before its own intent is durable, so a crash mid-batch replays the batch and resumes. - Tests: a mid-batch kill loses nothing and the rerun completes; batching changes no final path and no journal line. --- scripts/media-apply.py | 77 ++++++++++++++++--- .../Dankserver/Media Relayout.md | 7 ++ tests/media_relayout.bats | 59 ++++++++++++++ 3 files changed, 134 insertions(+), 9 deletions(-) diff --git a/scripts/media-apply.py b/scripts/media-apply.py index 88fe766..f497cf0 100755 --- a/scripts/media-apply.py +++ b/scripts/media-apply.py @@ -59,10 +59,18 @@ def journal_default(root): class Journal: - """Append-only JSONL of intents. Writes are fsynced before the tool acts.""" + """Append-only JSONL of intents. Writes are fsynced before the tool acts. - def __init__(self, path, writing): + ``batch`` is how many intents may be written before one fsync covers them. + The rule the file exists for is unchanged — an intent is on disk before the + move it authorises runs — so the caller must call ``sync()`` before it + performs a batch's renames; only the number of flushes falls. 1 = a flush + per intent (the original behaviour). + """ + + def __init__(self, path, writing, batch=1): self.path, self.writing = path, writing + self.batch = max(1, int(batch)) self.records = [] if os.path.exists(path): for line in open(path): @@ -70,11 +78,15 @@ class Journal: if line: self.records.append(json.loads(line)) self._fh = None + self._unsynced = 0 + # (src, dst) index: already() is called three times per move, so an + # any() over every record written so far made a 58k-move pass quadratic. + self._intents = {(r.get("src"), r.get("dst")) + for r in self.records if r.get("op") == "move"} def already(self, dst, src): """An intent for this exact (src, dst) was written by an earlier run.""" - return any(r.get("op") == "move" and r.get("dst") == dst - and r.get("src") == src for r in self.records) + return (src, dst) in self._intents @property def rolled_back(self): @@ -82,16 +94,28 @@ class Journal: def record(self, **fields): self.records.append(fields) + if fields.get("op") == "move": + self._intents.add((fields.get("src"), fields.get("dst"))) if not self.writing: return if self._fh is None: self._fh = open(self.path, "a") self._fh.write(json.dumps(fields) + "\n") self._fh.flush() + self._unsynced += 1 + if self._unsynced >= self.batch: + self.sync() + + def sync(self): + """Make every intent written so far durable. No-op when nothing pending.""" + if not self.writing or self._fh is None or not self._unsynced: + return os.fsync(self._fh.fileno()) + self._unsynced = 0 def close(self): if self._fh: + self.sync() self._fh.close() @@ -177,14 +201,30 @@ def do_apply(args, root, journal, mkdirs, keeps, moves): print(f" would move {src} -> {dst}") return 0 + batch = max(1, args.batch) made = {r.get("dst") for r in journal.records if r.get("op") == "mkdir"} dirs_new = 0 + queued = [] + + def drain_dirs(): + """Create the queued folders only after their intents are durable.""" + nonlocal dirs_new + if not queued: + return + journal.sync() + for d in queued: + os.makedirs(d, exist_ok=True) + dirs_new += 1 + queued.clear() + for d in sorted(mkdirs, key=lambda q: q.count(os.sep)): if d in made or os.path.isdir(d): continue journal.record(op="mkdir", dst=d) - os.makedirs(d, exist_ok=True) - dirs_new += 1 + queued.append(d) + if len(queued) >= batch: + drain_dirs() + drain_dirs() # same-filesystem gate: rename cannot cross a device, and a silent copy of a # library this size is not an option anyone would choose. @@ -201,6 +241,19 @@ def do_apply(args, root, journal, mkdirs, keeps, moves): return 2 moved = done = 0 + pending = [] + + def drain_moves(): + """Perform the queued renames only after their intents are on disk.""" + nonlocal moved + if not pending: + return + journal.sync() + for s, d in pending: + os.rename(s, d) + moved += 1 + pending.clear() + for src, dst, _kind in moves: if journal.already(dst, src): if os.path.exists(dst) and not os.path.exists(src): @@ -213,8 +266,10 @@ def do_apply(args, root, journal, mkdirs, keeps, moves): st = os.stat(src) journal.record(op="move", src=src, dst=dst, bytes=st.st_size, mode=st.st_mode, mtime=st.st_mtime, ino=st.st_ino, dev=st.st_dev) - os.rename(src, dst) - moved += 1 + pending.append((src, dst)) + if len(pending) >= batch: + drain_moves() + drain_moves() print(f"applied: {moved} moved, {done} already done in an earlier run," f" {dirs_new} folders created, {len(keeps)} files stayed put") print(f"journal: {journal.path} — undo with: media-apply.py rollback" @@ -418,13 +473,17 @@ def main(): ap.add_argument("--metadata-view", default=METADATA_VIEW, help="overlay the media servers read through; the sidecar" " note prints only when this path is a mountpoint") + ap.add_argument("--batch", type=int, default=64, + help="journal intents per fsync (1 = flush before every move)." + " An intent is still durable before the move it authorises;" + " only the number of flushes falls") args = ap.parse_args() root = os.path.abspath(args.root) if not os.path.isdir(root): raise Refuse(f"no library at {root}") journal_path = args.journal or journal_default(root) - journal = Journal(journal_path, args.apply) + journal = Journal(journal_path, args.apply, batch=args.batch) try: if args.cmd in ("rollback", "verify", "sweep") and not os.path.exists(journal_path): raise Refuse(f"no journal at {journal_path} — nothing to {args.cmd}") diff --git a/serverAppConfigs-docs/Dankserver/Media Relayout.md b/serverAppConfigs-docs/Dankserver/Media Relayout.md index d361960..b5508e3 100644 --- a/serverAppConfigs-docs/Dankserver/Media Relayout.md +++ b/serverAppConfigs-docs/Dankserver/Media Relayout.md @@ -81,6 +81,13 @@ An apply also leaves the release folders it emptied standing where they were — hundreds of them at the library root — which is what `sweep` removes, dry-run first, from the same journal. +`--batch N` (default 64) makes the journal durable per batch instead of per +move: N intents are written and fsynced, then those N rename. The rule a crash +relies on — no rename runs before its intent is on disk — is unchanged, so an +interrupted apply still replays the batch and resumes with nothing lost. Batch 1 +is the old behaviour; the only reason to name it is debugging, because on the +spinning array it spends most of a large apply's wall time in fsyncs. + After it lands, both servers need a scan or the change is invisible. [[Plex]] and [[Jellyfin]] own the exact requests; two laws hold at this step: `204` is acceptance and anything else queued nothing — re-issue the identical request diff --git a/tests/media_relayout.bats b/tests/media_relayout.bats index 3884a90..e3b2af4 100755 --- a/tests/media_relayout.bats +++ b/tests/media_relayout.bats @@ -494,6 +494,65 @@ for r in moves: PY } +# The live pass paid an fsync per move on a spinning array (63 074 of them) and +# the run took 11 minutes for 58 278 renames. --batch N writes N intents, syncs +# once, then performs those N moves — the durability rule is unchanged (no move +# runs before its intent is on disk), only the number of flushes falls. +@test "a batched apply journals every move and lands the same tree" { + plan + JOURNAL="$BATS_TEST_TMPDIR/batched.jsonl" + run python3 "$APPLY" apply --plan "$OUT" --root "$LIB" --journal "$JOURNAL" \ + --batch 4 --apply + [ "$status" -eq 0 ] + local planned journalled + planned=$(awk -F'\t' '$1=="move"' "$OUT" | wc -l) + journalled=$(grep -c '"op": "move"' "$JOURNAL") + [ "$journalled" -eq "$planned" ] + run python3 "$APPLY" verify --plan "$OUT" --root "$LIB" --journal "$JOURNAL" + [ "$status" -eq 0 ] + python3 - "$OUT" <<'PY' +import os, sys +moves = [r for r in (l.rstrip("\n").split("\t") for l in open(sys.argv[1])) + if r[0] == "move"] +assert moves, "fixture planned no moves" +for r in moves: + assert os.path.exists(r[2]), f"target missing: {r[2]}" + assert not os.path.exists(r[1]), f"source left behind: {r[1]}" +PY +} + +@test "an apply stopped mid-batch resumes without losing or double-journalling a move" { + plan + JOURNAL="$BATS_TEST_TMPDIR/interrupted-batch.jsonl" + python3 "$APPLY" apply --plan "$OUT" --root "$LIB" --journal "$JOURNAL" \ + --batch 2 --apply > "$BATS_TEST_TMPDIR/first.log" 2>&1 & + local pid=$! i + # stop the run the moment a batch of intents is on disk (it may also have + # finished already — both outcomes must resume cleanly) + for i in $(seq 1 200); do + [ -f "$JOURNAL" ] && [ "$(grep -c '"op": "move"' "$JOURNAL" 2>/dev/null || true)" -ge 2 ] && break + sleep 0.02 + done + kill -9 "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + + run python3 "$APPLY" apply --plan "$OUT" --root "$LIB" --journal "$JOURNAL" --batch 2 --apply + [ "$status" -eq 0 ] + run python3 "$APPLY" verify --plan "$OUT" --root "$LIB" --journal "$JOURNAL" + [ "$status" -eq 0 ] + python3 - "$OUT" "$JOURNAL" <<'PY' +import json, os, sys +moves = [r for r in (l.rstrip("\n").split("\t") for l in open(sys.argv[1])) + if r[0] == "move"] +journal = [json.loads(l) for l in open(sys.argv[2])] +dsts = [j["dst"] for j in journal if j.get("op") == "move"] +assert len(dsts) == len(set(dsts)), "a destination was journalled twice" +assert len(dsts) == len(moves), f"journalled {len(dsts)} of {len(moves)} moves" +for r in moves: + assert os.path.exists(r[2]) and not os.path.exists(r[1]), r[2] +PY +} + @test "rollback puts every file back where the census found it" { plan apply_plan From e82c8b2d06575cf572724288ad7a2962d314b707 Mon Sep 17 00:00:00 2001 From: dank <dank@franksdank.cloud> Date: Sat, 31 Oct 2026 18:17:02 -0700 Subject: [PATCH 13/18] fix(fleet): refuse a container start until the mount it binds has answered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dankserver rebooted with the array's mount and the media containers racing. A rootless quadlet cannot order itself against the system's mnt-entropy.mount, so `RequiresMountsFor=/mnt/entropy` is inert in the user manager: podman resolved the bind against the pre-mount directory, Jellyfin and Plex came up blind, and Jellyfin read an empty library and began `Removing item, Type: "Season"` by the thousand. Copyparty had no guard at all. `scripts/wait-for-mount.sh` is the gate: it waits for every named mountpoint, then refuses by name. `ConditionPathIsMountPoint=` is NOT the fix — measured on the host, an unmet condition logs `skipped, unmet condition check` with NRestarts=0 and no start attempt ever follows, which is fail-dead, not fail-closed. Jellyfin and Plex swap the condition for the gate; Copyparty gets one; the law is recorded in AGENTS.md and the view page. Verified on the host: the 5.7.0 quadlet generator accepts ExecStartPre in [Service] (exit 0, all three units generated, gate present), the gate waits for a mount that appears mid-window and proceeds, and it refuses a plain directory after its window naming the path. tests/media_overlay.bats pinned the old mechanism by name; its intent — both media servers refuse to start without the view — now rides the gate. --- AGENTS.md | 9 ++ .../copyparty/quadlet/copyparty.container | 3 + .../jellyfin/quadlet/jellyfin.container | 10 +- dankserver/plex/quadlet/plex.container | 5 +- scripts/wait-for-mount.sh | 74 ++++++++++++++ .../Dankserver/Media Metadata View.md | 31 ++++-- tests/array_mount_gate.bats | 96 +++++++++++++++++++ tests/media_overlay.bats | 5 +- 8 files changed, 217 insertions(+), 16 deletions(-) create mode 100755 scripts/wait-for-mount.sh create mode 100755 tests/array_mount_gate.bats diff --git a/AGENTS.md b/AGENTS.md index 0206de6..363e88e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -156,6 +156,15 @@ Unit requirements: (`<host>/<svc>/quadlet/`), is installed by the deployer, and is covered by the quadlet test seam. The host-side unit is generated state; the repo file is the source of truth. +- **Bind-source gate** — a unit whose `Volume=` source is a mount this host owns + (the entropy array, the metadata view) carries + `ExecStartPre=/home/dank/serverAppConfigs/scripts/wait-for-mount.sh <source>`. + Measured 2026-10-31: in a **rootless** unit `RequiresMountsFor=` is inert (the + user manager cannot order against the system's mount unit), and + `ConditionPathIsMountPoint=` is fail-*dead* — an unmet condition logs `skipped, + unmet condition check` with `NRestarts=0` and no start attempt ever follows. + Bind the array before it is mounted and the container sees the empty pre-mount + directory as truth; a media scan then deletes the library it cannot see. Before declaring a service deployed: `systemctl --user is-enabled <name>` → `enabled`, `is-active` → `active`, and the container answers its health check. diff --git a/dankserver/copyparty/quadlet/copyparty.container b/dankserver/copyparty/quadlet/copyparty.container index 76a31b0..08386d5 100644 --- a/dankserver/copyparty/quadlet/copyparty.container +++ b/dankserver/copyparty/quadlet/copyparty.container @@ -36,6 +36,9 @@ Volume=/home/dank/Containers/copyparty/cfg:/cfg PublishPort=3923:3923 [Service] +# /w is the array: serving the pre-mount directory instead of the volume shows an +# empty share and nothing complains, so the mount has to answer first. +ExecStartPre=/home/dank/serverAppConfigs/scripts/wait-for-mount.sh /mnt/entropy EnvironmentFile=/home/dank/Containers/copyparty/env Restart=always RestartSec=5 diff --git a/dankserver/jellyfin/quadlet/jellyfin.container b/dankserver/jellyfin/quadlet/jellyfin.container index 4152131..65f45b9 100644 --- a/dankserver/jellyfin/quadlet/jellyfin.container +++ b/dankserver/jellyfin/quadlet/jellyfin.container @@ -43,11 +43,10 @@ PublishPort=1900:1900/udp Volume=/home/dank/Containers/jellyfin/config:/config Volume=/home/dank/Containers/jellyfin/cache:/cache # The view, writable: Jellyfin is the metadata writer (movie.nfo / tvshow.nfo / -# episode.nfo + artwork) and those bytes belong on the SSD. The condition is the -# fail-closed guard — starting against a plain, unmounted view directory would let -# a scan conclude the library is empty. +# episode.nfo + artwork) and those bytes belong on the SSD. The gate in [Service] +# is the guard: started against a plain, unmounted view directory a scan decides +# the library is empty and deletes what it cannot see. Volume=/var/lib/media-meta/view:/data -UnitConfigItem=ConditionPathIsMountPoint=/var/lib/media-meta/view AddDevice=/dev/dri GroupAdd=keep-groups HealthCmd=curl -f http://localhost:8096/health @@ -56,6 +55,9 @@ HealthTimeout=10s HealthRetries=3 [Service] +# Refuse to start until the bind source is a real mount — see the header of +# scripts/wait-for-mount.sh for the blind-boot incident this prevents. +ExecStartPre=/home/dank/serverAppConfigs/scripts/wait-for-mount.sh /var/lib/media-meta/view /mnt/entropy Restart=always RestartSec=5 diff --git a/dankserver/plex/quadlet/plex.container b/dankserver/plex/quadlet/plex.container index ca6354e..8e93cfa 100644 --- a/dankserver/plex/quadlet/plex.container +++ b/dankserver/plex/quadlet/plex.container @@ -16,10 +16,13 @@ Volume=/var/lib/plexmediaserver:/config # to the overlay view re-points Plex at the sidecars without touching its DB. # Read-only: Plex consumes the NFO files Jellyfin writes and writes no metadata. Volume=/var/lib/media-meta/view:/mnt/entropy:ro -UnitConfigItem=ConditionPathIsMountPoint=/var/lib/media-meta/view PublishPort=32400:32400 [Service] +# Refuse to start until the bind source is a real mount: Plex blind is how paths +# get marked missing in its own database. Both the view and the array under it +# must answer — scripts/wait-for-mount.sh names the 2026-10-31 incident. +ExecStartPre=/home/dank/serverAppConfigs/scripts/wait-for-mount.sh /var/lib/media-meta/view /mnt/entropy Restart=always RestartSec=5 diff --git a/scripts/wait-for-mount.sh b/scripts/wait-for-mount.sh new file mode 100755 index 0000000..fc77343 --- /dev/null +++ b/scripts/wait-for-mount.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# wait-for-mount.sh — refuse a container start until what it binds is mounted. +# +# Why this exists (live 2026-10-31, Dankserver): a rootless quadlet cannot order +# itself against a SYSTEM mount unit. `RequiresMountsFor=/mnt/entropy` in a user +# unit resolves to nothing and is inert, so when the array's mount finished a +# second after podman resolved the bind, Jellyfin and Plex started against the +# pre-mount directory. Their library root looked EMPTY and Jellyfin began +# deleting metadata by the thousand (`Removing item, Type: "Season"`). A blind +# bind is not a cosmetic fault; it is a metadata-deleting machine. +# +# A `ConditionPathIsMountPoint=` is NOT the fix, measured on the same host: an +# unmet condition logs "skipped, unmet condition check", NRestarts=0, and no +# start attempt ever follows — the unit stays dead until a human starts it. +# That is fail-dead. This script WAITS (the mount usually lands within seconds) +# and only then refuses, so `Restart=always` carries the unit across the race. +# +# usage: wait-for-mount.sh [-t SECONDS] MOUNTPOINT [MOUNTPOINT...] +# exit 0 every path is a mountpoint +# exit 1 a path is still not a mountpoint after the window (named on stderr) +# exit 2 usage error +set -euo pipefail + +usage() { printf 'usage: %s [-t SECONDS] MOUNTPOINT [MOUNTPOINT...]\n' "$0"; } + +window=90 +while getopts ":t:h" opt; do + case "$opt" in + t) window=$OPTARG ;; + h) usage; exit 0 ;; + *) usage >&2; exit 2 ;; + esac +done +shift $((OPTIND - 1)) + +[ "$#" -ge 1 ] || { usage >&2; exit 2; } +case "$window" in + '' | *[!0-9]*) printf 'refused: -t wants whole seconds, got %s\n' "$window" >&2; exit 2 ;; +esac + +is_mount() { findmnt -M "$1" -rno TARGET >/dev/null 2>&1; } + +report() { + local why=$1; shift + printf '%s:' "$why" + for p in "$@"; do printf ' %s' "$p"; done + printf '\n' +} + +deadline=$(( $(date +%s) + window )) +warned=0 +while :; do + missing=() + for path in "$@"; do + is_mount "$path" || missing+=("$path") + done + [ "${#missing[@]}" -eq 0 ] && break + + if [ "$(date +%s)" -ge "$deadline" ]; then + report "refused: still not mounted after ${window}s" "${missing[@]}" >&2 + printf 'refusing to start: a container bind-mounted here would see the empty\n' >&2 + printf 'directory underneath and conclude the library is empty — which is how a\n' >&2 + printf 'scan begins deleting metadata it cannot find.\n' >&2 + exit 1 + fi + if [ "$warned" -eq 0 ]; then + report "waiting for mount(s) this unit depends on" "${missing[@]}" + warned=1 + fi + sleep 1 +done + +[ "$warned" -eq 0 ] || printf 'mounted; proceeding.\n' +exit 0 diff --git a/serverAppConfigs-docs/Dankserver/Media Metadata View.md b/serverAppConfigs-docs/Dankserver/Media Metadata View.md index 71aceb2..3a06509 100644 --- a/serverAppConfigs-docs/Dankserver/Media Metadata View.md +++ b/serverAppConfigs-docs/Dankserver/Media Metadata View.md @@ -46,10 +46,11 @@ view really is a mountpoint. | Installer | `scripts/media-view.sh` — `plan` / `install` / `verify` / `status` / `reap` | | Written by | Jellyfin only (Plex binds read-only); nothing else in the fleet touches it | -Both consumers guard the view with -`UnitConfigItem=ConditionPathIsMountPoint=/var/lib/media-meta/view`, and -`scripts/deploy.sh dankserver --service jellyfin|plex` calls `media-view.sh -install` + `verify` before it restarts either container. +Both consumers gate their start on the view with +`ExecStartPre=/home/dank/serverAppConfigs/scripts/wait-for-mount.sh` (it waits +for the mount, then refuses), and `scripts/deploy.sh dankserver --service +jellyfin|plex` calls `media-view.sh install` + `verify` before it restarts +either container. | Consumer | Bind | Mode | Why that path | |---|---|---|---| @@ -80,11 +81,20 @@ the defaults are the host values above. refuse via `lsblk -dno ROTA` if the upper base lives on the array: an overlay whose upper is the spinners is the original problem with extra steps, and it silently turns every metadata write back into a seek. -- **Never mount the view over an unmounted lower.** `RequiresMountsFor=/mnt/entropy` - plus the container `ConditionPathIsMountPoint` guards: an empty `lowerdir` - presents an *empty library* as truth, and a media scan would then clear the - database of everything. systemd does not create a mount point, so `install` - creates `view` itself; without it the mount simply fails. +- **A container that binds the array must refuse to start until the mount + answers.** Live 2026-10-31: the array's mount and the media containers raced at + boot, podman resolved the bind against the pre-mount directory, and both + servers came up blind — Jellyfin read an empty library and began + `Removing item, Type: "Season"` by the thousand. Two dependencies people reach + for do not work here, both measured on that host: `RequiresMountsFor=` in a + **rootless** unit is inert (the user manager cannot order against the system's + `mnt-entropy.mount`), and `ConditionPathIsMountPoint=` is fail-*dead* — an + unmet condition logs `skipped, unmet condition check` with `NRestarts=0` and no + start attempt ever follows, so the unit waits for a human. The working guard is + `scripts/wait-for-mount.sh` as `ExecStartPre`: it waits out the race, refuses by + name after its window, and `Restart=always` carries the unit from there. + systemd does not create a mount point, so `install` creates `view` itself; + without it the mount simply fails. - **Never delete through the view.** A delete writes an overlayfs whiteout (`.wh.<name>`) in the upper layer, which hides the array's copy without removing it — the file reappears the moment the view is unmounted, and the @@ -107,7 +117,8 @@ the defaults are the host values above. | Symptom | Meaning | |---|---| -| `jellyfin.service` / `plex.service` show `ConditionPathIsMountPoint=... was not met` and stay inactive | The view is not mounted. Do not start them anyway: `scripts/media-view.sh install`, then `verify`. | +| `jellyfin.service` / `plex.service` fail with `refused: still not mounted after 90s: …` | The view or the array is not mounted. Do not start them anyway: `scripts/media-view.sh install`, then `verify`. | +| Servers answer, library looks empty, Jellyfin logs `Removing item, Type: "Season"` by the thousand | Blind bind — the container started before the mount and bound the pre-mount directory. `podman exec jellyfin ls /data` against `ls /mnt/entropy` is the whole diagnosis; restarting the container re-resolves the bind. | | Jellyfin log: `Read-only file system : '/data/Movies/…/movie.nfo'` | The container is binding the raw array, not the view — the quadlet drifted back or the view is gone. `media-view.sh status`. | | A folder appears in a media server with artwork/`.nfo` but no video | Ghost metadata from a relayout. `media-view.sh reap`. | | `verify` prints `refused: …/view is not a mountpoint` | A plain directory is sitting where the overlay belongs. That is the fail-closed guard working; `install` on the box. | diff --git a/tests/array_mount_gate.bats b/tests/array_mount_gate.bats new file mode 100755 index 0000000..c44e745 --- /dev/null +++ b/tests/array_mount_gate.bats @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Seam: a container that binds the entropy array must refuse to start until the +# thing it binds is actually mounted. +# +# Live 2026-10-31, Dankserver: the array's systemd mount STARTED eight seconds +# before the media containers were created, but a rootless quadlet cannot order +# itself against a SYSTEM mount unit — `RequiresMountsFor=/mnt/entropy` resolves +# to nothing in the user manager and is simply inert. So podman resolved the bind +# against the pre-mount directory (which held one stale subdirectory) and both +# Jellyfin and Plex came up blind: their library root looked empty, Jellyfin +# logged thousands of `Removing item, Type: "Season"`, and the array sat at 80 % +# util doing nothing useful until the host went down. A blind bind is not a +# cosmetic fault — it is a metadata-deleting machine. +# +# The measured fix is `ExecStartPre=<repo>/scripts/wait-for-mount.sh <source>`: +# it WAITS for the mount and then refuses, so `Restart=always` carries the unit +# across a slow mount. A `ConditionPathIsMountPoint=` was measured on the same +# host and is the wrong tool: an unmet condition logs `skipped, unmet condition +# check` with NRestarts=0 and never a start attempt — the unit stays dead until +# a human starts it. That is fail-dead, not fail-closed. +# +# Tests assert only observable behavior of the gate (exit status and the name it +# refuses by) and the structural law over the quadlets; never the internals. + +load helpers + +GATE="$REPO_ROOT/scripts/wait-for-mount.sh" + +@test "the gate accepts a path that really is a mountpoint" { + run "$GATE" -t 2 /proc + [ "$status" -eq 0 ] || { printf '%s\n' "$output" >&2; return 1; } +} + +@test "a plain directory is refused, and refused BY NAME" { + dir="$BATS_TEST_TMPDIR/not-a-mount" + mkdir "$dir" + run "$GATE" -t 1 "$dir" + [ "$status" -ne 0 ] || { printf 'accepted a plain directory\n' >&2; return 1; } + [[ "$output" == *"$dir"* ]] || { printf 'refusal did not name the path: %s\n' "$output" >&2; return 1; } +} + +@test "a path that does not exist is refused by name" { + run "$GATE" -t 1 "$BATS_TEST_TMPDIR/does/not/exist" + [ "$status" -ne 0 ] + [[ "$output" == *"$BATS_TEST_TMPDIR/does/not/exist"* ]] +} + +@test "the gate holds the deadline instead of deciding on one look" { + # A single check that failed at the wrong instant is the boot race itself: + # the mount lands a second later and the unit is dead anyway. So the window + # must be spent waiting, and that is observable from outside as elapsed time. + dir="$BATS_TEST_TMPDIR/never" + mkdir "$dir" + start=$SECONDS + run "$GATE" -t 4 "$dir" + elapsed=$((SECONDS - start)) + [ "$status" -ne 0 ] + [ "$elapsed" -ge 4 ] || { printf 'gave up after %ss, window was 4s\n' "$elapsed" >&2; return 1; } +} + +@test "the gate accepts a mount that arrives during its window" { + dir="$BATS_TEST_TMPDIR/later" + mkdir "$dir" + if ! mount --bind /proc "$dir" 2>/dev/null; then + skip "creating a mount needs privileges this box does not grant" + fi + run "$GATE" -t 4 "$dir" + umount "$dir" 2>/dev/null || sudo -n umount "$dir" 2>/dev/null || true + [ "$status" -eq 0 ] || { printf 'gate did not wait for a mount that arrived: %s\n' "$output" >&2; return 1; } +} + +@test "no unit without the gate binds the array or the metadata view" { + bad="" + while IFS= read -r unit; do + sources=$(sed -n 's/^Volume=\([^:]*\):.*/\1/p' "$unit" \ + | grep -E '^(/mnt/entropy|/var/lib/media-meta/view)$' | sort -u) + [ -n "$sources" ] || continue + for src in $sources; do + grep -q "ExecStartPre=.*wait-for-mount.sh.* $src\b" "$unit" \ + || bad="$bad$unit binds $src with no start gate"$'\n' + done + done < <(find "$REPO_ROOT"/dankserver "$REPO_ROOT"/dankclaw "$REPO_ROOT"/frankbook-pro \ + -path '*/quadlet/*.container' -not -path '*.AGENTTRASH*') + [ -z "$bad" ] || { printf 'blind binds:\n%s' "$bad" >&2; return 1; } +} + +@test "no gated unit keeps the fail-dead ConditionPathIsMountPoint" { + bad="" + while IFS= read -r unit; do + grep -q "wait-for-mount.sh" "$unit" || continue + grep -q "ConditionPathIsMountPoint" "$unit" \ + && bad="$bad$unit: a gate and an unmet-condition skip cannot coexist"$'\n' + done < <(find "$REPO_ROOT"/dankserver "$REPO_ROOT"/dankclaw "$REPO_ROOT"/frankbook-pro \ + -path '*/quadlet/*.container' -not -path '*.AGENTTRASH*') + [ -z "$bad" ] || { printf 'fail-dead conditions:\n%s' "$bad" >&2; return 1; } +} diff --git a/tests/media_overlay.bats b/tests/media_overlay.bats index ee6a3e1..1bf526f 100755 --- a/tests/media_overlay.bats +++ b/tests/media_overlay.bats @@ -61,7 +61,10 @@ UNIT="$REPO_ROOT/dankserver/host/systemd/media-meta-view.mount" @test "both media servers bind the view and refuse to start without it" { for unit in "$JELLY" "$PLEX"; do grep -q "^Volume=$MOUNT:" "$unit" || return 1 - grep -q "^UnitConfigItem=ConditionPathIsMountPoint=$MOUNT$" "$unit" || return 1 + # The refusal is a start GATE, not a Condition: measured on the host, a + # unit whose Condition goes unmet is skipped forever (NRestarts=0, no + # start attempt) — fail-dead. The gate waits, then refuses. + grep -q "^ExecStartPre=.*wait-for-mount.sh .*$MOUNT" "$unit" || return 1 # The raw array must no longer be a container source: that bind is what # made sidecar sharing impossible (read-only) and metadata slow (seeks). ! grep -q '^Volume=/mnt/entropy' "$unit" From 183a6d5442c23fd5aa853ca9ac59e490a3958d23 Mon Sep 17 00:00:00 2001 From: dank <dank@franksdank.cloud> Date: Sat, 31 Oct 2026 18:42:27 -0700 Subject: [PATCH 14/18] feat(fleet): give the media servers an I/O share that actually exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Plex section scan walked 50k files at ~850 r/s and nothing bounded it. `IOWeight=` in a quadlet looks like the fix and does nothing: systemd delegates `cpu memory pids` to user@.service and NOT io, and it drops a resource line it cannot honour without a word — the container boots healthy and its cgroup has no io.weight file at all. Same family as RequiresMountsFor= (inert in the user manager) and ConditionPathIsMountPoint= (fail-dead). Place the two system drop-ins that make the controller reachable (user.slice IOAccounting=yes puts io in user.slice/cgroup.subtree_control, measured immediately; user@1000.service DelegateControllers gains io and lands at the next reboot, because restarting the user manager would kill every rootless container on the host), then ask for the share: IOWeight=10 on jellyfin and plex. 10 is a convention, not a measured optimum — proportional share, only bites under contention, only requirement is far below the default 100. ensure_user_io_delegate places both from git and prints NOTE: io is not delegated … inert until the next reboot, so the quadlet line never reads as live config. Verified on the host: subtree_control flipped to `cpu io memory pids`, the drop-in is attached and parsed (systemctl cat), and the podman 5.7 generator emits IOWeight=10 for both units (bare key; UnitConfigItem= not needed). Two limits recorded instead of hidden: a container's weight covers its whole cgroup (scan and playback share it), and the comparison happens inside user.slice, so it cannot push a rootless unit below a root unit such as frank-archon — that pairing is user.slice's own weight, a separate call. Docs: Quadlet Lifecycle 4b (the three-command read of the chain), Pi-hole (all rootless containers share one 10.0.2.100, so one chatty container throttles DNS for the host — 8 events today), E2E Suite gotcha 7 (the gate depends on the runner's resolver: 21 reds on a lost wired link, 37/37 minutes later). Seam: an in-repo IOWeight= may only exist when both drop-ins exist AND the deployer places them. ./tests/run.sh: 789 ok, exit 0. --- .../systemd/user.slice.d/io-accounting.conf | 14 +++++++ .../user@1000.service.d/io-delegate.conf | 12 ++++++ .../jellyfin/quadlet/jellyfin.container | 8 ++++ dankserver/plex/quadlet/plex.container | 6 +++ scripts/deploy-remote.sh | 41 +++++++++++++++++-- .../Architecture/E2E Suite.md | 9 ++++ .../Architecture/Quadlet Lifecycle.md | 37 +++++++++++++++++ .../Infrastructure/Pi-hole.md | 2 +- tests/dankserver_quadlet.bats | 21 ++++++++++ 9 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 dankserver/host/systemd/user.slice.d/io-accounting.conf create mode 100644 dankserver/host/systemd/user@1000.service.d/io-delegate.conf diff --git a/dankserver/host/systemd/user.slice.d/io-accounting.conf b/dankserver/host/systemd/user.slice.d/io-accounting.conf new file mode 100644 index 0000000..267021f --- /dev/null +++ b/dankserver/host/systemd/user.slice.d/io-accounting.conf @@ -0,0 +1,14 @@ +# The io controller is what makes IOWeight= mean anything, and systemd does not +# enable it below `user.slice` by default: with io accounting off, `io` never +# appears in `user.slice/cgroup.subtree_control`, so no rootless container can +# have an `io.weight` file and `IOWeight=` in a quadlet is silently inert. +# Measured on Dankserver 2026-10-31: setting this property flipped +# `user.slice/cgroup.subtree_control` from `cpu memory pids` to `cpu io memory +# pids` immediately, with nothing restarted. +# +# This is half the fix; the other half is delegating io to user@1000.service +# (user@1000.service.d/io-delegate.conf). Both must be in place before a reboot, +# because the delegation is read when the user manager starts. Law and the +# verification command: [[Quadlet Lifecycle]]. +[Slice] +IOAccounting=yes diff --git a/dankserver/host/systemd/user@1000.service.d/io-delegate.conf b/dankserver/host/systemd/user@1000.service.d/io-delegate.conf new file mode 100644 index 0000000..9f45d8f --- /dev/null +++ b/dankserver/host/systemd/user@1000.service.d/io-delegate.conf @@ -0,0 +1,12 @@ +# The system manager gives the user manager cpu/memory/pids by default (systemd +# 259: `systemctl show user@1000.service -p DelegateControllers` → `cpu memory +# pids`). Without `io` in that set no `io.weight` file can exist anywhere below +# `user.slice`, so `IOWeight=` in a rootless quadlet is silently inert — the same +# class of trap as `RequiresMountsFor=`. Granting the controller here is what +# makes the media servers' I/O share real. +# +# Inert until `user@1000.service` starts again, i.e. the next reboot (restarting +# it would kill every rootless container on the host). Verify after the reboot +# with the check in [[Quadlet Lifecycle]]. +[Service] +DelegateControllers=cpu memory pids io diff --git a/dankserver/jellyfin/quadlet/jellyfin.container b/dankserver/jellyfin/quadlet/jellyfin.container index 65f45b9..318b712 100644 --- a/dankserver/jellyfin/quadlet/jellyfin.container +++ b/dankserver/jellyfin/quadlet/jellyfin.container @@ -61,5 +61,13 @@ ExecStartPre=/home/dank/serverAppConfigs/scripts/wait-for-mount.sh /var/lib/medi Restart=always RestartSec=5 +# A library scan reads the array for minutes on end. Weighted LOW (default is +# 100) so that when the array is contended a scan loses to playback and to every +# other container this user runs. Proportional share, not a cap: it changes +# nothing while the disk is idle. 10 is a convention — the requirement is only +# that it sit far below 100 — and it is inert until user@1000.service is +# delegated the io controller ([[Quadlet Lifecycle]]). +IOWeight=10 + [Install] WantedBy=default.target diff --git a/dankserver/plex/quadlet/plex.container b/dankserver/plex/quadlet/plex.container index 8e93cfa..554fd09 100644 --- a/dankserver/plex/quadlet/plex.container +++ b/dankserver/plex/quadlet/plex.container @@ -26,5 +26,11 @@ ExecStartPre=/home/dank/serverAppConfigs/scripts/wait-for-mount.sh /var/lib/medi Restart=always RestartSec=5 +# See the jellyfin unit for the reasoning: a section scan gets a LOW weight +# (default 100) so a walk of 50k files loses the array to playback and to every +# other container under this user. Inert until user@1000.service holds the io +# controller ([[Quadlet Lifecycle]]). +IOWeight=10 + [Install] WantedBy=default.target diff --git a/scripts/deploy-remote.sh b/scripts/deploy-remote.sh index bcb3825..04790e5 100755 --- a/scripts/deploy-remote.sh +++ b/scripts/deploy-remote.sh @@ -1208,14 +1208,47 @@ ensure_media_view() { # array below stays read-only and every sidecar Jellyfin writes lands on the # NVMe. # Raise it BEFORE either container starts — a start against a bare view - # directory would let a scan clear the library database. The units also carry - # ConditionPathIsMountPoint, so a missing view skips the start rather than - # damaging anything. A relayout must be followed by `media-view.sh reap` + # directory would let a scan clear the library database. Each unit gates on + # scripts/wait-for-mount.sh, which waits and then refuses the start. A + # relayout must be followed by `media-view.sh reap` # (see the Media Relayout page), which this is not. run_privileged bash scripts/media-view.sh install bash scripts/media-view.sh verify } +ensure_user_io_delegate() { + # The media servers ask for an I/O share (IOWeight= in their quadlets) so a + # library scan cannot starve playback or the array's other reader. Two system + # drop-ins are what make that request real, and both are host state, so the + # deployer places them: + # user.slice.d/io-accounting.conf io enters user.slice's subtree + # user@1000.service.d/io-delegate.conf the user manager gets the controller + # Without the first, `io` never appears in user.slice/cgroup.subtree_control, + # so no container under it can have an io.weight file at all; without the + # second the user manager never asks for the controller. systemd hands + # user@.service cpu/memory/pids by default and drops a line it cannot honour + # without a word — IOWeight= on its own is inert config. + local src dst + for src in \ + dankserver/host/systemd/user.slice.d/io-accounting.conf \ + dankserver/host/systemd/user@1000.service.d/io-delegate.conf; do + dst=/etc/systemd/system/${src#dankserver/host/systemd/} + run_privileged mkdir -p "$(dirname "$dst")" + if ! run_privileged cmp -s "$src" "$dst"; then + run_privileged install -m 644 "$src" "$dst" + run_privileged systemctl daemon-reload + fi + done + # Accounting lands immediately; the delegation is read when user@1000.service + # starts, and restarting that unit would kill every rootless container on this + # host. So the share becomes real at the next reboot — say so out loud rather + # than let a quadlet line read as live config. + if ! systemctl show user@1000.service -p DelegateControllers | grep -qw io; then + echo "NOTE: io is not delegated to user@1000.service yet - IOWeight= is" + echo "NOTE: inert until the next reboot (see [[Quadlet Lifecycle]])" + fi +} + ensure_provider_keys() { # Jellyfin's metadata-provider keys live in the container-owned plugin # configuration under the data root, never in git; scripts/jellyfin-providers.sh @@ -1237,6 +1270,7 @@ ensure_provider_keys() { deploy_jellyfin() { ensure_media_view + ensure_user_io_delegate ensure_provider_keys cp "dankserver/jellyfin/quadlet/jellyfin.container" "$quadlet_unit_dir/" systemctl --user daemon-reload @@ -1326,6 +1360,7 @@ deploy_copyparty() { deploy_plex() { ensure_media_view + ensure_user_io_delegate cp "dankserver/plex/quadlet/plex.container" "$quadlet_unit_dir/" systemctl --user daemon-reload if ! systemctl --user is-active -q plex.service; then diff --git a/serverAppConfigs-docs/Architecture/E2E Suite.md b/serverAppConfigs-docs/Architecture/E2E Suite.md index 7b47684..9dbe08f 100644 --- a/serverAppConfigs-docs/Architecture/E2E Suite.md +++ b/serverAppConfigs-docs/Architecture/E2E Suite.md @@ -100,3 +100,12 @@ aborts with `DEPLOY INCOMPLETE: e2e gate FAILED` when it does not pass hand-off list (`HANDOFFS`) instead of a single host. The guard contract is untouched: challenge cleared, no authentik `uidField`, status < 500, and every host NOT in the list still has to land on its own vhost. +7. **The runner's resolver is part of the suite.** A 2026-10-31 deploy of one + Dankserver quadlet failed the gate with 21 reds spread over SSO, homepage RBAC + and every vhost crawl — precisely the specs that navigate to an absolute + `*.franksdank.cloud` name, while every spec on the relative `baseURL` stayed + green. The error was `net::ERR_NAME_NOT_RESOLVED`: the workstation had lost its + wired link, and on the fallback its resolver was no longer pinned to Pi-hole. + Nothing on the fleet was wrong — the same suite re-run minutes later passed + 37/37. Before a mass red gets blamed on the guard chain, run + `resolvectl query grafana.franksdank.cloud` on the box that runs the gate. diff --git a/serverAppConfigs-docs/Architecture/Quadlet Lifecycle.md b/serverAppConfigs-docs/Architecture/Quadlet Lifecycle.md index 410d35f..f5da2b7 100644 --- a/serverAppConfigs-docs/Architecture/Quadlet Lifecycle.md +++ b/serverAppConfigs-docs/Architecture/Quadlet Lifecycle.md @@ -167,6 +167,43 @@ Gate: `tests/quadlet_image_pins.bats`. Where upstream publishes no version tags (copyparty-ac stops at 1.19.15), pin `:latest@sha256:` and name the version the config label carries in the comment beside it. +## 4b. Resource control needs host permission (measured 2026-10-31, Dankserver) + +`IOWeight=` in a rootless quadlet does nothing on its own, and nothing says so. +The system manager delegates `cpu memory pids` to `user@.service` and **not** `io`; +systemd drops a resource line it cannot honour **without a word** — the generator +still emits `IOWeight=10`, the container still starts and answers its health check, +and the cgroup simply has no `io.weight` file. Same family as +`RequiresMountsFor=` (inert in the user manager) and `ConditionPathIsMountPoint=` +(fail-dead): the line is accepted, the unit is happy, the setting is dead. + +Two system drop-ins make it real, both placed by `ensure_user_io_delegate` in +`scripts/deploy-remote.sh` (repo copies under `dankserver/host/systemd/`): + +| File | Effect | +|---|---| +| `user.slice.d/io-accounting.conf` → `[Slice] IOAccounting=yes` | puts `io` into `user.slice/cgroup.subtree_control`; lands immediately, no restart | +| `user@1000.service.d/io-delegate.conf` → `[Service] DelegateControllers=cpu memory pids io` | the user manager can only request controllers it was delegated; read when it starts, so it lands on **the next reboot** (restarting it kills every rootless container on the host) | + +Read the chain in this order — each line is the failure mode of the one after it: + +```bash +cat /sys/fs/cgroup/user.slice/cgroup.subtree_control # wants: io +systemctl show user@1000.service -p DelegateControllers # wants: io +cat /sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/\<unit\>.service/libpod-payload-*/io.weight +``` + +`io` missing from the first → the accounting drop-in is absent. Missing from the +second → the host has not rebooted since the delegation landed (the deployer prints +`NOTE: io is not delegated …` for exactly this). No file at the third → that unit +carries no weight. + +Two limits that survive the fix: a container's weight covers **everything** in its +cgroup, so a library scan and a playback stream share one weight; and the comparison +is made *inside* `user.slice`, so a rootless unit's weight cannot push it below a +root unit (`frank-archon`). Shaping that pairing is the weight of `user.slice` +itself — a fleet decision, not a unit line. + ## 5. Rootless Quadlet log retrieval Step 0 — establish ownership: `systemctl --user status <name>.service` diff --git a/serverAppConfigs-docs/Infrastructure/Pi-hole.md b/serverAppConfigs-docs/Infrastructure/Pi-hole.md index 6f52cef..c5cdb69 100644 --- a/serverAppConfigs-docs/Infrastructure/Pi-hole.md +++ b/serverAppConfigs-docs/Infrastructure/Pi-hole.md @@ -77,7 +77,7 @@ Both prerequisites survive the dnsmasq → Pi-hole swap. Do not remove them. | Port 53 `bind: permission denied` crash loop after a reboot | sysctl conflict: the value must be 53 at boot, and remember `/etc/sysctl.conf` is applied **after** all of `sysctl.d/`. Redeploy pihole — `deploy_pihole` reinstalls the drop-in, neutralizes conflicting drop-ins, and comments out a stale `/etc/sysctl.conf` line | | Single-label name (for example `dankclaw`) answers `127.0.1.1` | The rootless container `/etc/hosts` carries the host entry (`127.0.1.1 <host>`), and FTL serves it. Use the FQDN, or add an `/etc/hosts` entry on the client | | Every `franksdank.cloud` name is NXDOMAIN for LAN clients while external names still answer | FTL holds no local records: `getent hosts <name>` on Dankclaw comes back empty → `podman exec pihole pihole restartdns reload` (the 2026-10-19 pitfall under **Records**) | -| One client gets nothing while every other client is fine | FTL rate-limiting that client: `podman exec pihole grep Rate-limiting /var/log/pihole/FTL.log`. A `10.0.2.x` client address is a pasta container's DNS forwarder, not a machine | +| One client gets nothing while every other client is fine | FTL rate-limiting that client: `podman exec pihole grep Rate-limiting /var/log/pihole/FTL.log`. A `10.0.2.x` client address is a pasta container's DNS forwarder, not a machine — and **every** rootless container on that host shares the one address, so a single chatty container throttles DNS for all of them (measured 2026-10-31: 8 throttle events in a day, all `10.0.2.100`) | ## See Also diff --git a/tests/dankserver_quadlet.bats b/tests/dankserver_quadlet.bats index dd87e0a..532c48a 100755 --- a/tests/dankserver_quadlet.bats +++ b/tests/dankserver_quadlet.bats @@ -106,3 +106,24 @@ load helpers # env file, not an Environment= line. ! grep -qE '^Environment=CUSTOM_(USER|PASSWORD)=' "$unit" || return 1 } + +@test "a quadlet that asks for an I/O share ships the host permission that makes it real" { + # IOWeight= is silently inert unless the system manager both enables io for + # user.slice and delegates it to the user manager: systemd drops a resource + # line it cannot honour without a word and the container still starts healthy. + # So an in-repo weight is only honest together with both drop-ins AND with a + # deployer that places them. Measured 2026-10-31; verification chain in + # Architecture/Quadlet Lifecycle 4b. + if grep -lq '^IOWeight=' "$REPO_ROOT"/dankserver/*/quadlet/*.container; then + asked=1 + else + skip "no dankserver quadlet asks for an I/O share" + fi + grep -q '^IOAccounting=yes$' \ + "$REPO_ROOT/dankserver/host/systemd/user.slice.d/io-accounting.conf" || return 1 + grep -q '^DelegateControllers=.* io' \ + "$REPO_ROOT/dankserver/host/systemd/user@1000.service.d/io-delegate.conf" || return 1 + for f in user.slice.d/io-accounting.conf 'user@1000.service.d/io-delegate.conf'; do + grep -q "dankserver/host/systemd/$f" "$REPO_ROOT/scripts/deploy-remote.sh" || return 1 + done +} From 8ae4d1c7102f0a8db8eac7e08e475385c4603cac Mon Sep 17 00:00:00 2001 From: dank <dank@franksdank.cloud> Date: Sat, 31 Oct 2026 18:46:53 -0700 Subject: [PATCH 15/18] =?UTF-8?q?chore:=20stop=20tracking=20.AGENTTRASH=20?= =?UTF-8?q?=E2=80=94=20the=20purger=20deletes=20tracked=20files,=20dirtyin?= =?UTF-8?q?g=20every=20host?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deletion policy moves in-repo deletions into .AGENTTRASH/ beside the item, and .gitignore line 25 already declares it ignored. But 37 retired payloads were committed before that rule existed, and gitignore never applies to tracked paths. Consequence measured 2026-10-31: Dankserver's deploy checkout reported 37 tracked deletions — exactly every tracked trash file — because the approved purger had done its job. Any host that purges is permanently dirty, and a future pull that touches one of those paths aborts mid-deploy with 'local changes would be overwritten' while the tree is in fact clean of real edits. Untrack the four dirs; the bytes stay on disk and in history (they always were recoverable from git, which is what the trash policy's 'temporary hold, not an archive' means). tests/deployment_scoping.bats asserts the staged chat template exists on disk — it still does, so the seam is untouched. --- .AGENTTRASH/frank-default-small.env.llama.cpp | 71 --- .../frank-default-small.service.llama.cpp | 16 - .AGENTTRASH/frank-dragoon.env.llama.cpp | 126 ----- .AGENTTRASH/frank-dragoon.service.llama.cpp | 16 - .AGENTTRASH/frank-npu.env.flm-npu | 23 - .AGENTTRASH/frank-npu.service.flm-npu | 17 - .AGENTTRASH/serve-frank-dragoon.sh.llama.cpp | 180 ------- .AGENTTRASH/serve-frank-npu.sh.flm-npu | 62 --- .AGENTTRASH/serve-frank-small.sh.llama.cpp | 87 ---- .../20260916-vllm-radiance/affinity-proxy.py | 156 ------ .../frank-medium-a.container | 98 ---- .../frank-medium-b.container | 98 ---- .../frank-medium-proxy.container | 61 --- .../20260916-vllm-radiance/routing.py | 129 ----- .../bench-spec-ab.py.llamacpp-layersplit | 309 ------------ .../frank-medium.env.llamacpp-layersplit | 318 ------------ .../frank-medium.service.llamacpp-layersplit | 20 - .../serve-frank-medium.sh.llamacpp-layersplit | 203 -------- ...ench-decode-threads-batch.py.strix-rocmfp4 | 192 -------- .../bench-ngram-mod.py.ik-llama.cpp | 268 ----------- .../bench-ngram-mod.sh.ik-llama.cpp | 304 ------------ .../bench-spec-decode.py.ik-llama.cpp | 229 --------- scripts/.AGENTTRASH/bench-vllm-corpus.py | 85 ---- scripts/.AGENTTRASH/bench-vllm-serve.sh | 63 --- .../chat-template.jinja.ik-llama-qwen-sharp | 453 ------------------ .../.AGENTTRASH/download-q8agent.sh.rocmfpx | 86 ---- scripts/.AGENTTRASH/frank-default-npu.env | 17 - scripts/.AGENTTRASH/frank-default-npu.service | 17 - .../frank-dragoon.env.ik-llama.cpp | 105 ---- .../frank-dragoon.service.ik-llama.cpp | 21 - scripts/.AGENTTRASH/frank-medium.env.rocmfpx | 138 ------ .../.AGENTTRASH/frank-medium.service.rocmfpx | 16 - .../.AGENTTRASH/serve-frank-default-npu.sh | 49 -- .../serve-frank-dragoon.sh.ik-llama.cpp | 123 ----- .../.AGENTTRASH/serve-frank-medium.sh.rocmfpx | 215 --------- tests/.AGENTTRASH/frank_medium_proxy.bats | 145 ------ tests/.AGENTTRASH/frank_medium_quadlet.bats | 164 ------- 37 files changed, 4680 deletions(-) delete mode 100644 .AGENTTRASH/frank-default-small.env.llama.cpp delete mode 100644 .AGENTTRASH/frank-default-small.service.llama.cpp delete mode 100644 .AGENTTRASH/frank-dragoon.env.llama.cpp delete mode 100644 .AGENTTRASH/frank-dragoon.service.llama.cpp delete mode 100644 .AGENTTRASH/frank-npu.env.flm-npu delete mode 100644 .AGENTTRASH/frank-npu.service.flm-npu delete mode 100755 .AGENTTRASH/serve-frank-dragoon.sh.llama.cpp delete mode 100755 .AGENTTRASH/serve-frank-npu.sh.flm-npu delete mode 100755 .AGENTTRASH/serve-frank-small.sh.llama.cpp delete mode 100755 dankserver/.AGENTTRASH/20260916-vllm-radiance/affinity-proxy.py delete mode 100644 dankserver/.AGENTTRASH/20260916-vllm-radiance/frank-medium-a.container delete mode 100644 dankserver/.AGENTTRASH/20260916-vllm-radiance/frank-medium-b.container delete mode 100644 dankserver/.AGENTTRASH/20260916-vllm-radiance/frank-medium-proxy.container delete mode 100755 dankserver/.AGENTTRASH/20260916-vllm-radiance/routing.py delete mode 100755 scripts/.AGENTTRASH/20260920-llama-layersplit/bench-spec-ab.py.llamacpp-layersplit delete mode 100644 scripts/.AGENTTRASH/20260920-llama-layersplit/frank-medium.env.llamacpp-layersplit delete mode 100644 scripts/.AGENTTRASH/20260920-llama-layersplit/frank-medium.service.llamacpp-layersplit delete mode 100755 scripts/.AGENTTRASH/20260920-llama-layersplit/serve-frank-medium.sh.llamacpp-layersplit delete mode 100755 scripts/.AGENTTRASH/bench-decode-threads-batch.py.strix-rocmfp4 delete mode 100755 scripts/.AGENTTRASH/bench-ngram-mod.py.ik-llama.cpp delete mode 100755 scripts/.AGENTTRASH/bench-ngram-mod.sh.ik-llama.cpp delete mode 100755 scripts/.AGENTTRASH/bench-spec-decode.py.ik-llama.cpp delete mode 100755 scripts/.AGENTTRASH/bench-vllm-corpus.py delete mode 100755 scripts/.AGENTTRASH/bench-vllm-serve.sh delete mode 100644 scripts/.AGENTTRASH/chat-template.jinja.ik-llama-qwen-sharp delete mode 100755 scripts/.AGENTTRASH/download-q8agent.sh.rocmfpx delete mode 100644 scripts/.AGENTTRASH/frank-default-npu.env delete mode 100644 scripts/.AGENTTRASH/frank-default-npu.service delete mode 100644 scripts/.AGENTTRASH/frank-dragoon.env.ik-llama.cpp delete mode 100644 scripts/.AGENTTRASH/frank-dragoon.service.ik-llama.cpp delete mode 100644 scripts/.AGENTTRASH/frank-medium.env.rocmfpx delete mode 100644 scripts/.AGENTTRASH/frank-medium.service.rocmfpx delete mode 100755 scripts/.AGENTTRASH/serve-frank-default-npu.sh delete mode 100755 scripts/.AGENTTRASH/serve-frank-dragoon.sh.ik-llama.cpp delete mode 100755 scripts/.AGENTTRASH/serve-frank-medium.sh.rocmfpx delete mode 100755 tests/.AGENTTRASH/frank_medium_proxy.bats delete mode 100755 tests/.AGENTTRASH/frank_medium_quadlet.bats diff --git a/.AGENTTRASH/frank-default-small.env.llama.cpp b/.AGENTTRASH/frank-default-small.env.llama.cpp deleted file mode 100644 index d11f10c..0000000 --- a/.AGENTTRASH/frank-default-small.env.llama.cpp +++ /dev/null @@ -1,71 +0,0 @@ -# Tunables for frank-default-small (Tiel-Coder-35B-A3B UD-Q6_K_XL, -# qwen35moe hybrid MoE, Dankserver, RTX 3090). -# -# Runtime: upstream llama.cpp (latest master, CUDA build with -# -DGGML_CUDA_FA_ALL_QUANTS=ON -DCMAKE_CUDA_ARCHITECTURES=86) on RTX 3090 -# 24 GB; Ryzen 7 7700X; 62 GB RAM. Replaced the ik_llama.cpp Ornith -# CRACK Q8_0 deployment on 2026-09-01. -# -# Model: peculiar-ragdoll/Tiel-Coder-35B-A3B-GGUF, UD-Q6_K_XL quant -# (30 GB). Architecture: qwen35moe — 40 layers, full_attention_interval=4 -# (10 attention + 30 SSM/Mamba), 256 experts / 8 used, 2 KV heads × 256 -# dim, embedding 2048, rope dimension_count 64, rope freq_base 10 000 000. -# Text-only (no mmproj). Built-in chat template in the GGUF. -# -# Expert offload: --cpu-moe keeps all 256 expert weights in CPU RAM. -# Non-expert weights (~1.6 GB) fit entirely on the 24 GB card. -# -# KV cache: Q8_0 for both K and V. With CTX_SIZE=200700 (66 900 per slot -# × 3 parallel) and 10 attention layers, the KV cache is ~2 GB — the -# requested 2 GB VRAM buffer. -# -# WARNING: do not name any variable TEMP, HOST, or GPU_MAX_HEAP_SIZE. The -# ROCm HIP runtime reads those bare env vars (see the 2026-08-13 TEMP -# incident). This box is NVIDIA/CUDA, but the rule keeps the env portable. - -# Model file (UD-Q6_K_XL, 30 GB, text-only MoE hybrid). -MODEL_DIR=/home/dank/models/tiel-coder-35b -MODEL_FILE=Tiel-Coder-35B-A3B-UD-Q6_K_XL.gguf - -# Upstream llama.cpp llama-server (CUDA build, latest master). -# Built with -DGGML_CUDA_FA_ALL_QUANTS=ON -DCMAKE_CUDA_ARCHITECTURES=86 -# using the staged CUDA 13.2 toolkit at /home/dank/cuda13-stage. -LLAMA_SERVER=/home/dank/llama.cpp/build/bin/llama-server - -ALIAS=frank-default-small -BIND_ADDR=0.0.0.0 -LISTEN_PORT=7770 - -# Total context budget across all parallel slots. 200700 / 3 = 66 900 -# per slot. The model's context_length is 262 144, so 66 900 is well -# within range. -CTX_SIZE=200700 -PARALLEL=3 - -# Expert offload: all MoE expert weights pinned to CPU RAM. -CPU_MOE=1 - -# All non-expert layers on GPU (auto-fit will determine the exact count; -# with --cpu-moe the non-expert footprint is ~1.6 GB, leaving ample room). -N_GPU_LAYERS=999 - -# KV cache: Q8_0 for both K and V. ~2 GB total for 200700 ctx × 3 slots -# × 10 attention layers × (2 KV heads × 256 dim × 1 byte) × 2 (K+V). -CACHE_TYPE_K=q8_0 -CACHE_TYPE_V=q8_0 - -VERBOSITY=2 - -# Prefill batch sizing. -BATCH_SIZE=4096 -UBATCH_SIZE=1024 - -# Sampling. -TEMPERATURE=1.0 -TOP_K=20 -TOP_P=0.95 -MIN_P=0.0 - -# Compute threads lowered so other processes keep a core. -THREADS=7 -THREADS_HTTP=8 \ No newline at end of file diff --git a/.AGENTTRASH/frank-default-small.service.llama.cpp b/.AGENTTRASH/frank-default-small.service.llama.cpp deleted file mode 100644 index 0805e2c..0000000 --- a/.AGENTTRASH/frank-default-small.service.llama.cpp +++ /dev/null @@ -1,16 +0,0 @@ -[Unit] -Description=llama.cpp Tiel-Coder-35B-A3B Q6_K_XL qwen35moe hybrid MoE (dankserver, frank-default-small) -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -User=dank -Group=dank -Environment="FRANK_SMALL_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-default-small.env" -ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-small.sh -Restart=on-failure -RestartSec=10 - -[Install] -WantedBy=multi-user.target diff --git a/.AGENTTRASH/frank-dragoon.env.llama.cpp b/.AGENTTRASH/frank-dragoon.env.llama.cpp deleted file mode 100644 index ff82436..0000000 --- a/.AGENTTRASH/frank-dragoon.env.llama.cpp +++ /dev/null @@ -1,126 +0,0 @@ -# Tunables for frank-dragoon (kingjones777/Qwen3.8-Flash-Next-ROCmFP4- -# STRIX-GGUF on the kingjones30/ROCmFPX llama.cpp runtime, Dankclaw, -# Strix Halo gfx1151, unified memory). -# -# Runtime: kingjones30/ROCmFPX (fork of charlie12345/ROCmFPX with the -# qwen4exp-on-rocmfpx-d3ca537 patch applied — the model card's pinned -# runtime: ROCmFP4 tensor types AND the qwen4exp architecture in one -# tree; stock llama.cpp and charlie's fork alone cannot load this GGUF). -# Built per the card recipe: -# cmake -B build -DGGML_HIP=ON -DGPU_TARGETS=gfx1151 -DGGML_NATIVE=ON -DCMAKE_BUILD_TYPE=Release -# at /home/dank/GitPackages/kingjones-ROCmFPX/build. -# Replaced the agentionai ROCmFP4-FAST imatrix deployment (charlie's -# build-qwen38 runtime) on 2026-08-30. -# -# Model: Qwen3.8-Flash-Next base (qwen4exp MoE: 512 experts / 10 used, -# 48 layers = 12 attention + 36 SSM, QSA indexer, hyper-connections, PLE -# at layer 1), Q4_0_ROCMFP4_STRIX (file_type 105), 113.47 GiB single -# file. STRIX is the top quality tier: PLE table at Q8_0 (50.66 GiB), -# token embeddings + lm head at Q6_K. The PLE table NEVER enters GPU -# memory — it streams from the file through the OS page cache. Two hard -# card constraints: -# 1. mmap MUST stay enabled (default) — --no-mmap forces the 50.66 GiB -# table into anonymous memory and the cgroup killer takes the -# process with nothing in the server log (only dmesg). -# 2. Do NOT pin the PLE tensor to CPU with -ot — measured gen fell -# ~23 -> 13.4 tok/s; the kernel streams it better. -# NOTE: the card's own build_attn_qsa still calls ggml_fill on the -# F16-cast kq mask (the path that aborted our charlie build with -# --flash-attn on before scripts/patches/qwen4exp-qsa-f32-mask.patch). -# If a clean kingjones-tree build aborts graph build with -# GGML_ASSERT(a-type == GGML_TYPE_F32), port that fix (F32 base mask + -# cast at the attention call) into this tree and rebuild. -# -# WARNING: do not name any variable TEMP, HOST, or GPU_MAX_HEAP_SIZE. The ROCm HIP runtime reads those bare env vars (see the 2026-08-13 TEMP -# incident). llama.cpp reads --host/--port/--temp as CLI flags, never as -# env vars. - -# kingjones30/ROCmFPX runtime binary (card recipe build). -LLAMA_BIN=/home/dank/GitPackages/kingjones-ROCmFPX/build/bin/llama-server - -# Model package from -# https://huggingface.co/kingjones777/Qwen3.8-Flash-Next-ROCmFP4-STRIX-GGUF -# Layout: single unsharded GGUF, text-only (the repo ships no mmproj; -# the vision tower below is borrowed from the agentionai FAST package — -# same base model, f16 — kept only if it loads clean, drop -# MMPROJ_PATH if it does not). -MODEL_DIR=/home/dank/models/kingjones-Qwen3.8-Flash-Next-ROCmFP4-STRIX -MODEL_FILE=Qwen3.8-Flash-Next-Q4_0-ROCmFP4-STRIX.gguf -MMPROJ_PATH=/home/dank/models/agentionai-Qwen3.8-Flash-Next-ROCmFP4-FAST/mmproj/mmproj-Qwen3.8-Flash-Next-f16.gguf - -# Qwen-Sharp chat template (tracked in the repo, staged on the host). The -# Sharp template drives thinking on by default (medium effort); the server -# emits reasoning in DeepSeek format so clients receive reasoning_content -# separately instead of literal thinking tags in content. -CHAT_TEMPLATE_FILE=/home/dank/serverAppConfigs/scripts/chat-template.jinja -CHAT_TEMPLATE_KWARGS='{"enable_thinking":true,"preserve_thinking":true,"preserve_reasoning":true}' - -# Alias + network. -ALIAS=frank-dragoon -BIND_ADDR=0.0.0.0 -LISTEN_PORT=7770 - -# Serving: 401408-token context, TWO slots (parallel 2), batch 4096 / -# ubatch 512, 16+16 threads, f16 KV, 16 context checkpoints, 8 GiB -# prompt cache. --fit off: auto-fit segfaults on this model's -# hyper-connection/indexer config; -ngl 999 puts every layer on the -# SVM-backed GPU pool. ubatch 512 matches the card's re-measurement -# (-ub 1024/2048/4096 showed no effect; -t 32 vs 16 is noise). -# -# CTX_SIZE=401408 / PARALLEL=2: carried over from the FAST deployment -# where it loaded clean (~74 s, health 200). The card measures the full -# 262144 window at ~71.7 GiB GTT at load on the same 128 GB board; QSA -# caps KV growth so the extra window is cheap. KV footprint scales -# with the 12 attention layers only. Verify load on first STRIX boot. -CTX_SIZE=401408 -PARALLEL=2 - -BATCH_SIZE=4096 -UBATCH_SIZE=512 -NUM_THREADS=16 -THREAD_BATCH=16 -CACHE_TYPE_K=f16 -CACHE_TYPE_V=f16 -CTX_CHECKPOINTS=16 -CACHE_RAM=8192 -SLOT_SAVE_PATH=/home/dank/serverAppConfigs/scripts/slot-state -SPLIT_MODE=none -FIT=off -NGL=999 -FLASH_ATTN=on - -# Sampling (card instruct table, non-thinking). -TEMPERATURE=0.7 -TOP_K=20 -TOP_P=0.8 -MIN_P=0.0 -PRESENCE_PENALTY=1.5 - -# Reasoning on (Sharp template drives it; preserve_thinking + preserve_reasoning -# keep the reasoning trail in the conversation). DeepSeek format projects the -# reasoning into the API's reasoning_content field instead of literal thinking -# tags. -REASONING=on -REASONING_FORMAT=deepseek - -# Strix Halo unified memory + native gfx1151. -HSA_OVERRIDE_GFX_VERSION=11.5.1 - -VERBOSITY=4 - - -# Speculative decoding: disabled. The card's Usage block ships no spec -# flags. The Flash-Next MTP heads do not load on this loader (requires -# per-layer blk.N.hc_attn_norm; every head carries only blk.48 — -# verified 3x on build-qwen38). DRAFT_MODEL_* point at the retired -# agentionai FAST head and are unused while SPEC_TYPE=none. -DRAFT_MODEL_DIR=/home/dank/models/mtp-Qwen3.8-Flash-Next-FAST -DRAFT_MODEL_FILE=Qwen3.8-Flash-Next-MTP-ROCmFP4-FAST.gguf -SPEC_TYPE=none -SPEC_DRAFT_N_MAX=3 -SPEC_DRAFT_N_MIN=0 -SPEC_DRAFT_P_MIN=0.75 -SPEC_DRAFT_NGL=999 -SPEC_DRAFT_DEVICE=ROCm0 -SPEC_DRAFT_TYPE_K=f16 -SPEC_DRAFT_TYPE_V=f16 \ No newline at end of file diff --git a/.AGENTTRASH/frank-dragoon.service.llama.cpp b/.AGENTTRASH/frank-dragoon.service.llama.cpp deleted file mode 100644 index 6e166e1..0000000 --- a/.AGENTTRASH/frank-dragoon.service.llama.cpp +++ /dev/null @@ -1,16 +0,0 @@ -[Unit] -Description=ROCmFPX (kingjones30 fork) llama.cpp Qwen3.8-Flash-Next ROCmFP4 STRIX 177B MoE (dankclaw, frank-dragoon) -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -User=dank -Group=dank -Environment="FRANK_DRAGOON_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-dragoon.env" -ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-dragoon.sh -Restart=on-failure -RestartSec=10 - -[Install] -WantedBy=multi-user.target diff --git a/.AGENTTRASH/frank-npu.env.flm-npu b/.AGENTTRASH/frank-npu.env.flm-npu deleted file mode 100644 index adb9c08..0000000 --- a/.AGENTTRASH/frank-npu.env.flm-npu +++ /dev/null @@ -1,23 +0,0 @@ -# Tunables for frank-npu (FastFlowLM NPU endpoint, Dankclaw) -# Runtime: FLM 1.0.1 (/usr/bin/flm) on the XDNA2 NPU (50 TOPS). Boot tag is -# nuextract3:4b (NuExtract3-4B-NPU2, Qwen3.5-4B skeleton, structured-extraction -# model); serve routes every request by the exact FLM registry tag in the -# request's model field, so any registered tag (gemma4-it:e4b, qwythos:9b, ...) -# is reachable on this port regardless of the boot tag. The NPU is a -# single-lane device: generations serialize FIFO, and the first request after a -# model swap pays the ~6-11 s load cost. The NPU draws from the unified RAM -# pool shared with frank-dragoon (iGPU). -FLM_MODEL=nuextract3:4b -FLM_HOST=0.0.0.0 -FLM_PORT=7771 -# turbo = max NPU power mode (xrt-smi configure --pmode turbo at serve start). -# No other tenant uses the NPU on this host, so the top mode costs nothing to -# neighbours; drop to `performance` if iGPU power-budget contention ever shows. -FLM_PMODE=turbo -# FLM_ASR=1 additionally loads Whisper-V3-Turbo-NPU2 (+0.62 GB) and exposes -# /v1/audio/transcriptions. NuExtract3 answers text-only; audio stays off. -FLM_ASR=0 -# -1 = no --ctx-len flag: every pack loads with its own trained context -# (nuextract3:4b -> 32768, gemma4-it:e4b -> 131072). A global override would -# over-allocate KV for the small packs. -FLM_CTX_LEN=-1 diff --git a/.AGENTTRASH/frank-npu.service.flm-npu b/.AGENTTRASH/frank-npu.service.flm-npu deleted file mode 100644 index 42400c0..0000000 --- a/.AGENTTRASH/frank-npu.service.flm-npu +++ /dev/null @@ -1,17 +0,0 @@ -[Unit] -Description=FastFlowLM NPU sight+sound inference (frank-npu) on Dankclaw -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -User=dank -Group=dank -Environment="FRANK_NPU_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-npu.env" -ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-npu.sh -LimitMEMLOCK=infinity -Restart=on-failure -RestartSec=10 - -[Install] -WantedBy=multi-user.target diff --git a/.AGENTTRASH/serve-frank-dragoon.sh.llama.cpp b/.AGENTTRASH/serve-frank-dragoon.sh.llama.cpp deleted file mode 100755 index 0fcaa24..0000000 --- a/.AGENTTRASH/serve-frank-dragoon.sh.llama.cpp +++ /dev/null @@ -1,180 +0,0 @@ -#!/usr/bin/env bash -# Serve kingjones777/Qwen3.8-Flash-Next-ROCmFP4-STRIX-GGUF as -# frank-dragoon via the kingjones30/ROCmFPX llama.cpp runtime on -# Dankclaw (Strix Halo gfx1151, unified memory). -# -# Runtime: kingjones30/ROCmFPX (fork of charlie12345/ROCmFPX with the -# model card's qwen4exp-on-rocmfpx patch) built at -# /home/dank/GitPackages/kingjones-ROCmFPX/build (HIP, gfx1151, -# ROCm 7.2.4, card recipe). The ROCmFP4 quant types plus the qwen4exp -# architecture must be in one tree; stock llama.cpp cannot load this -# GGUF. -# -# Model: Qwen3.8-Flash-Next base (qwen4exp MoE: 512 experts / 10 used, -# 48 layers = 12 attention + 36 SSM, QSA indexer, hyper-connections, PLE -# at layer 1) quantized Q4_0_ROCMFP4_STRIX, 113.47 GiB single file. The -# 50.66 GiB PLE n-gram table stays OUT of GPU memory and streams via -# mmap from the OS page cache — never add --no-mmap or -ot for it. -# -# Profile: card sampling table (temp 0.7, top_p 0.80, top_k 20, min_p 0.0, -# presence 1.5 — non-thinking instruct) mixed with the validated serving -# profile: ctx 401408 x 2 slots, b 4096 / ub 512, t 16 / tb 16, f16 KV, -# fa on (if the graph build aborts on the QSA F16 mask, see the note in -# the env file: port scripts/patches/qwen4exp-qsa-f32-mask.patch into -# this tree and rebuild), cont-batching, prompt cache + 8 GiB cache-ram -# + idle-slot cache, 16 ctx checkpoints, --fit off (auto-fit segfaults -# on qwen4exp hyper-connection config). Speculative decoding off: the -# Flash-Next MTP heads do not load on this loader; the card ships no -# spec flags. -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -ENV_FILE="${FRANK_DRAGOON_ENV_FILE:-$SCRIPT_DIR/frank-dragoon.env}" -if [[ -f "$ENV_FILE" ]]; then - set -a - # shellcheck disable=SC1090 - source "$ENV_FILE" - set +a -fi - -# --- defaults (used when the env file does not set them) ------------------- -: "${LLAMA_BIN:=/home/dank/GitPackages/kingjones-ROCmFPX/build/bin/llama-server}" -: "${MODEL_DIR:=/home/dank/models/kingjones-Qwen3.8-Flash-Next-ROCmFP4-STRIX}" -: "${MODEL_FILE:=Qwen3.8-Flash-Next-Q4_0-ROCmFP4-STRIX.gguf}" -: "${MMPROJ_PATH:=/home/dank/models/agentionai-Qwen3.8-Flash-Next-ROCmFP4-FAST/mmproj/mmproj-Qwen3.8-Flash-Next-f16.gguf}" -: "${CHAT_TEMPLATE_FILE:=$SCRIPT_DIR/chat-template.jinja}" -: "${CHAT_TEMPLATE_KWARGS:={}}" -: "${ALIAS:=frank-dragoon}" -: "${BIND_ADDR:=0.0.0.0}" -: "${LISTEN_PORT:=7770}" -: "${CTX_SIZE:=401408}" -: "${PARALLEL:=2}" -: "${BATCH_SIZE:=4096}" -: "${UBATCH_SIZE:=512}" -: "${NUM_THREADS:=16}" -: "${THREAD_BATCH:=16}" -: "${CACHE_TYPE_K:=f16}" -: "${CACHE_TYPE_V:=f16}" -: "${CTX_CHECKPOINTS:=16}" -: "${CACHE_RAM:=8192}" -: "${SLOT_SAVE_PATH:=$SCRIPT_DIR/slot-state}" -: "${SPLIT_MODE:=none}" -: "${FIT:=off}" -: "${NGL:=999}" -: "${FLASH_ATTN:=on}" -: "${TEMPERATURE:=0.7}" -: "${TOP_K:=20}" -: "${TOP_P:=0.8}" -: "${MIN_P:=0.0}" -: "${PRESENCE_PENALTY:=1.5}" -: "${REASONING:=on}" -: "${REASONING_FORMAT:=deepseek}" -: "${HSA_OVERRIDE_GFX_VERSION:=11.5.1}" -: "${SPEC_TYPE:=none}" -: "${DRAFT_MODEL_DIR:=/home/dank/models/mtp-Qwen3.8-Flash-Next-FAST}" -: "${DRAFT_MODEL_FILE:=Qwen3.8-Flash-Next-MTP-ROCmFP4-FAST.gguf}" -: "${SPEC_DRAFT_N_MAX:=3}" -: "${SPEC_DRAFT_N_MIN:=0}" -: "${SPEC_DRAFT_P_MIN:=0.75}" -: "${SPEC_DRAFT_NGL:=999}" -: "${SPEC_DRAFT_DEVICE:=ROCm0}" -: "${SPEC_DRAFT_TYPE_K:=f16}" -: "${SPEC_DRAFT_TYPE_V:=f16}" - -MODEL="$MODEL_DIR/$MODEL_FILE" -for f in "$LLAMA_BIN" "$MODEL" "$CHAT_TEMPLATE_FILE"; do - if [[ ! -r "$f" ]]; then - echo "missing or unreadable required artifact: $f" >&2 - exit 1 - fi -done -if [[ ! -x "$LLAMA_BIN" ]]; then - echo "missing executable runtime: $LLAMA_BIN" >&2 - exit 1 -fi - -# --- speculative decoding (MTP draft head) --------------------------------- -# The MTP draft head is a separate GGUF sidecar loaded via --spec-draft-model -# (alias --model-draft). ngram-only specs do not need a draft file. -# Set SPEC_TYPE=none in the env to disable speculative decoding entirely. -SPEC_ARGS=() -if [[ -n "$SPEC_TYPE" && "$SPEC_TYPE" != "none" ]]; then - SPEC_ARGS=( - --spec-type "$SPEC_TYPE" - --spec-draft-n-max "$SPEC_DRAFT_N_MAX" - --spec-draft-n-min "$SPEC_DRAFT_N_MIN" - --spec-draft-p-min "$SPEC_DRAFT_P_MIN" - ) - if [[ "$SPEC_TYPE" == *draft-mtp* ]]; then - DRAFT_MODEL="$DRAFT_MODEL_DIR/$DRAFT_MODEL_FILE" - if [[ ! -r "$DRAFT_MODEL" ]]; then - echo "missing or unreadable draft model: $DRAFT_MODEL" >&2 - exit 1 - fi - SPEC_ARGS+=( - --spec-draft-model "$DRAFT_MODEL" - --spec-draft-ngl "$SPEC_DRAFT_NGL" - --spec-draft-device "$SPEC_DRAFT_DEVICE" - --spec-draft-type-k "$SPEC_DRAFT_TYPE_K" - --spec-draft-type-v "$SPEC_DRAFT_TYPE_V" - ) - fi - if [[ -n "${SPEC_DRAFT_P_SPLIT:-}" ]]; then - SPEC_ARGS+=(--spec-draft-p-split "$SPEC_DRAFT_P_SPLIT") - fi -fi - -# The STRIX repo ships no mmproj; this is the agentionai FAST package's -# f16 vision tower for the same base model. Passed only when readable. -MMPROJ_ARGS=() -if [[ -n "${MMPROJ_PATH:-}" && -r "${MMPROJ_PATH:-}" ]]; then - MMPROJ_ARGS=(--mmproj "$MMPROJ_PATH") -fi - -SERVER_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -mkdir -p "$SLOT_SAVE_PATH" -ROCM_PATH="${ROCM_PATH:-/opt/rocm}" -LIBRARY_PATH="$SERVER_DIR:$ROCM_PATH/lib:$ROCM_PATH/lib/rocm_sysdeps/lib:$ROCM_PATH/lib/llvm/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" - -exec /usr/bin/env \ - ROCM_PATH="$ROCM_PATH" \ - HIP_VISIBLE_DEVICES=0 \ - LD_LIBRARY_PATH="$LIBRARY_PATH" \ - HSA_OVERRIDE_GFX_VERSION="$HSA_OVERRIDE_GFX_VERSION" \ - "$LLAMA_BIN" \ - --model "$MODEL" \ - --alias "$ALIAS" \ - --chat-template-file "$CHAT_TEMPLATE_FILE" \ - --chat-template-kwargs "$CHAT_TEMPLATE_KWARGS" \ - --host "$BIND_ADDR" \ - --port "$LISTEN_PORT" \ - --jinja \ - --device ROCm0 \ - --gpu-layers "$NGL" \ - --split-mode "$SPLIT_MODE" \ - --parallel "$PARALLEL" \ - --ctx-size "$CTX_SIZE" \ - --batch-size "$BATCH_SIZE" \ - --ubatch-size "$UBATCH_SIZE" \ - --fit "$FIT" \ - --flash-attn "$FLASH_ATTN" \ - --cache-type-k "$CACHE_TYPE_K" \ - --cache-type-v "$CACHE_TYPE_V" \ - --threads "$NUM_THREADS" \ - --threads-batch "$THREAD_BATCH" \ - --ctx-checkpoints "$CTX_CHECKPOINTS" \ - --cache-ram "$CACHE_RAM" \ - --slot-save-path "$SLOT_SAVE_PATH" \ - --cache-prompt \ - --cache-idle-slots \ - --metrics \ - --temp "$TEMPERATURE" \ - --top-p "$TOP_P" \ - --top-k "$TOP_K" \ - --min-p "$MIN_P" \ - --presence-penalty "$PRESENCE_PENALTY" \ - --reasoning "$REASONING" \ - --reasoning-format "$REASONING_FORMAT" \ - --reasoning-budget -1 \ - "${MMPROJ_ARGS[@]}" \ - "${SPEC_ARGS[@]}" \ No newline at end of file diff --git a/.AGENTTRASH/serve-frank-npu.sh.flm-npu b/.AGENTTRASH/serve-frank-npu.sh.flm-npu deleted file mode 100755 index 61a60d1..0000000 --- a/.AGENTTRASH/serve-frank-npu.sh.flm-npu +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env bash -# Serve the FastFlowLM (FLM) sight+sound endpoint — frank-npu. -# -# Runs `flm serve <tag>` on the XDNA2 NPU (50 TOPS, Dankclaw). The NPU is a -# single-lane device: generations serialize in FIFO order, so this is a -# background/triage endpoint, not an interactive multi-user chat. The NPU -# draws from the unified RAM pool shared with frank-dragoon (the iGPU -# llama.cpp service); see serverAppConfigs-docs/LLM/frank-npu.md -# for the memory budget and the model-selection tradeoff. -# -# NOTE: FLM requires unlimited memlock. The unit sets LimitMEMLOCK=infinity. -# NOTE: an unknown model tag does NOT 404 — /v1/chat/completions answers HTTP 200 with a -# flat {"error":"Failed to load <tag> model!"} body and no choices; /v1/completions returns a -# nested OpenAI-shaped server_error (code 500) also under HTTP 200. Client model IDs must be the exact FLM registry -# tag (e.g. gemma4-it:e4b), never a service alias. Custom tags only resolve -# when FLM_CONFIG_PATH points at the user registry that carries them and their -# kernels are symlinked into /home/dank/.config/flm/xclbins/<PackName>. -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ENV_FILE="${FRANK_NPU_ENV_FILE:-$SCRIPT_DIR/frank-npu.env}" -if [[ -f "$ENV_FILE" ]]; then - set -a - # shellcheck disable=SC1090 - source "$ENV_FILE" - set +a -fi - -# --- defaults (used when the env file does not set them) ------------------- -: "${FLM_BIN:=/usr/bin/flm}" -: "${FLM_MODEL:=gemma4-it:e4b}" -: "${FLM_HOST:=0.0.0.0}" -: "${FLM_PORT:=7771}" -: "${FLM_PMODE:=turbo}" -: "${FLM_ASR:=0}" -: "${FLM_CTX_LEN:=131072}" - -# The user registry is a superset of the system registry (it carries the -# custom tags too), and its model_path resolves relative to the registry file, -# so exports keep a tag swap in the env file from needing script edits. -export FLM_CONFIG_PATH="${FLM_CONFIG_PATH:-/home/dank/.config/flm/model_list.json}" -export FLM_XCLBIN_PATH="${FLM_XCLBIN_PATH:-/home/dank/.config/flm/xclbins}" - -if [[ ! -x "$FLM_BIN" ]]; then - echo "missing flm binary: $FLM_BIN" >&2 - exit 1 -fi - -# --- optional flags ---------------------------------------------------------- -OPT_ARGS=() -if [[ "${FLM_ASR}" != "0" ]]; then - OPT_ARGS+=(--asr "${FLM_ASR}") -fi -if [[ "${FLM_CTX_LEN}" != "-1" ]]; then - OPT_ARGS+=(--ctx-len "${FLM_CTX_LEN}") -fi - -exec "$FLM_BIN" serve "$FLM_MODEL" \ - --host "$FLM_HOST" \ - -p "$FLM_PORT" \ - --pmode "$FLM_PMODE" \ - "${OPT_ARGS[@]}" diff --git a/.AGENTTRASH/serve-frank-small.sh.llama.cpp b/.AGENTTRASH/serve-frank-small.sh.llama.cpp deleted file mode 100755 index 5b3b6e8..0000000 --- a/.AGENTTRASH/serve-frank-small.sh.llama.cpp +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env bash -# Serve peculiar-ragdoll/Tiel-Coder-35B-A3B (UD-Q6_K_XL) as -# frank-default-small via stock llama.cpp on Dankserver (RTX 3090). -# -# Runtime: upstream llama.cpp (latest master, CUDA build with -# -DGGML_CUDA_FA_ALL_QUANTS=ON -DCMAKE_CUDA_ARCHITECTURES=86). -# The model is a 35B-total / 8-active MoE hybrid (qwen35moe arch) with -# SSM/Mamba layers (full_attention_interval=4: 10 attention + 30 SSM -# layers out of 40). 256 experts, 8 used per token. -# -# Expert offload: --cpu-moe keeps all 256 expert weights in CPU RAM. -# Non-expert weights (attention, SSM, shared expert, embeddings, output) -# are ~1.6 GB and fit entirely on the 24 GB RTX 3090. -# -# KV cache: Q8_0 for both K and V. With CTX_SIZE=200700 (66 900 per -# slot × 3 parallel) and 10 attention layers (2 KV heads × 256 dim), -# the KV cache is ~2 GB — the requested 2 GB VRAM buffer. -# -# Model is loaded with mmap (default). The 30 GB Q6_K_XL file is -# memory-mapped; only accessed pages enter RAM. -# -# Tunables come from frank-default-small.env unless FRANK_SMALL_ENV_FILE -# overrides that path. -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ENV_FILE="${FRANK_SMALL_ENV_FILE:-$SCRIPT_DIR/frank-default-small.env}" -if [[ -f "$ENV_FILE" ]]; then - set -a - # shellcheck disable=SC1090 - source "$ENV_FILE" - set +a -fi - -# --- defaults (used when the env file does not set them) ----------------- -: "${MODEL_DIR:=/home/dank/models/tiel-coder-35b}" -: "${MODEL_FILE:=Tiel-Coder-35B-A3B-UD-Q6_K_XL.gguf}" -: "${LLAMA_SERVER:=/home/dank/llama.cpp/build/bin/llama-server}" -: "${ALIAS:=frank-default-small}" -: "${BIND_ADDR:=0.0.0.0}" -: "${LISTEN_PORT:=7770}" -: "${CTX_SIZE:=200700}" -: "${PARALLEL:=3}" -: "${CACHE_TYPE_K:=q8_0}" -: "${CACHE_TYPE_V:=q8_0}" -: "${N_GPU_LAYERS:=999}" -: "${BATCH_SIZE:=4096}" -: "${UBATCH_SIZE:=1024}" -: "${VERBOSITY:=2}" -: "${CPU_MOE:=1}" -: "${TEMPERATURE:=1.0}" -: "${TOP_K:=20}" -: "${TOP_P:=0.95}" -: "${MIN_P:=0.0}" -: "${THREADS:=7}" -: "${THREADS_HTTP:=8}" - -MODEL="$MODEL_DIR/$MODEL_FILE" - -if [[ ! -f "$MODEL" ]]; then - echo "missing required file: $MODEL" >&2 - exit 1 -fi -if [[ ! -x "$LLAMA_SERVER" ]]; then - echo "missing llama-server binary: $LLAMA_SERVER" >&2 - exit 1 -fi - -exec "$LLAMA_SERVER" \ - --model "$MODEL" \ - $([ "$CPU_MOE" = "1" ] && printf -- "--cpu-moe") \ - --cache-type-k "$CACHE_TYPE_K" \ - --cache-type-v "$CACHE_TYPE_V" \ - --ctx-size "$CTX_SIZE" \ - --parallel "$PARALLEL" \ - --gpu-layers "$N_GPU_LAYERS" \ - --batch-size "$BATCH_SIZE" \ - --ubatch-size "$UBATCH_SIZE" \ - --temp "$TEMPERATURE" \ - --top-k "$TOP_K" \ - --top-p "$TOP_P" \ - --min-p "$MIN_P" \ - --threads "$THREADS" \ - --threads-http "$THREADS_HTTP" \ - --alias "$ALIAS" \ - --host "$BIND_ADDR" \ - --port "$LISTEN_PORT" \ No newline at end of file diff --git a/dankserver/.AGENTTRASH/20260916-vllm-radiance/affinity-proxy.py b/dankserver/.AGENTTRASH/20260916-vllm-radiance/affinity-proxy.py deleted file mode 100755 index e526e8c..0000000 --- a/dankserver/.AGENTTRASH/20260916-vllm-radiance/affinity-proxy.py +++ /dev/null @@ -1,156 +0,0 @@ -#!/usr/bin/env python3 -"""HTTP front door for the frank-medium single-GPU replicas. - -Why two replicas instead of TP=2: one vLLM process spanning both R9700s needs -RCCL collectives, and RCCL's kernels require PCIe atomics on every -participating GPU. The second card sits behind chipset bridges advertising -`AtomicOpsCap: Routing-`, which no software can enable (see the runbook), so -no collective can launch on it. Two single-GPU replicas use both cards; this -service keeps the fleet contract of one endpoint on :7770, streaming included. - -Routing decisions live in routing.py — spread single-turn requests, pin -continuations to the replica whose prefix cache holds their context. This file -only moves bytes: parse enough of the request to route it, forward it verbatim, -stream the answer back, and report replica health on /health. -""" - -from __future__ import annotations - -import asyncio -import os -from contextlib import suppress -from aiohttp import ClientSession, ClientTimeout, web - -import routing - -PORT = int(os.environ.get("PORT", "7770")) -HEALTH_TTL = float(os.environ.get("HEALTH_TTL", "10")) -# Hop-by-hop headers must not be forwarded (RFC 9110 §7.6.1) — forwarding -# transfer-encoding or content-length of a rewritten body breaks clients. -HOP_BY_HOP = { - "host", "content-length", "transfer-encoding", "connection", - "keep-alive", "proxy-authenticate", "proxy-authorization", "te", - "trailers", "upgrade", -} - -UPSTREAMS = routing.UPSTREAMS -# Replica health, refreshed by the watcher. Module state rather than aiohttp app -# state: the application is frozen once the site is up. -_UP: list[bool] = [True] * len(UPSTREAMS) - - -async def health(session: ClientSession) -> web.Response: - results = await asyncio.gather( - *(session.get(u + "/health", timeout=ClientTimeout(total=5)) for u in UPSTREAMS), - return_exceptions=True, - ) - lines, up = [], 0 - for url, res in zip(UPSTREAMS, results): - if isinstance(res, Exception) or res.status != 200: - lines.append(f"{url} DOWN") - else: - up += 1 - lines.append(f"{url} UP") - return web.Response(status=200 if up else 503, text="\n".join(lines) + "\n") - - -async def watch_health() -> None: - """Refresh the replica up/down table so routing never targets a dead one. - - One HTTP request per replica per interval, in exchange for never handing a - client a 502 after a restart. - """ - global _UP - session = ClientSession() - try: - while True: - res = await asyncio.gather( - *(session.get(u + "/health", timeout=ClientTimeout(total=5)) for u in UPSTREAMS), - return_exceptions=True, - ) - _UP = [not isinstance(r, Exception) and r.status == 200 for r in res] - await asyncio.sleep(HEALTH_TTL) - finally: - await session.close() - - -async def proxy(request: web.Request) -> web.StreamResponse: - session: ClientSession = request.app["session"] - if request.path == "/health": - return await health(session) - - body = await request.read() - headers = {k: v for k, v in request.headers.items() if k.lower() not in HOP_BY_HOP} - - # Fail over before anything is written to the client. A replica that - # restarts is invisible to the health watcher for up to HEALTH_TTL, and a - # deploy restarts both replicas — without this retry a 10 s window of - # requests would 502 on a service that has a live card the whole time. - # vLLM's OpenAI endpoints are stateless per request, so re-sending a - # request that never reached an engine is safe; a request that failed - # mid-stream is not retried (that path is below). - tried: set[int] = set() - upstream = None - last_error = "no healthy frank-medium replica" - while len(tried) < len(UPSTREAMS): - idx = routing.route(body, request.remote, _UP) - if idx < 0 or idx in tried: - break - tried.add(idx) - target = UPSTREAMS[idx] + request.path_qs - try: - upstream = await session.request( - request.method, target, headers=headers, data=body or None, - timeout=ClientTimeout(total=None, sock_connect=10, sock_read=3600), - ) - break - except Exception as exc: - # Demote at once: the watcher will confirm, but every request until - # then should skip this replica rather than pay the same timeout. - # Logged because a silent failover hides a card that is down. - _UP[idx] = False - last_error = f"{target} unreachable: {exc}" - print(f"proxy: failover — {target} unreachable ({exc}); retrying " - f"another replica", flush=True) - if upstream is None: - # 503 when no replica was even reachable through the routing table, - # 502 when replicas were tried and all of them failed. - status = 502 if tried else 503 - return web.Response(status=status, text=f"proxy: {last_error}\n") - - out = web.StreamResponse(status=upstream.status, reason=upstream.reason) - for k, v in upstream.headers.items(): - if k.lower() not in HOP_BY_HOP: - out.headers[k] = v - await out.prepare(request) - try: - async for chunk in upstream.content.iter_any(): - await out.write(chunk) - except Exception: - # The client went away mid-stream (an aborted generation). Nothing to - # report: the engine sees its own cancellation. - pass - finally: - upstream.release() - with suppress(Exception): - await out.write_eof() - return out - - -async def main() -> None: - app = web.Application(client_max_size=1024 ** 3) - app["session"] = ClientSession() - app.router.add_route("*", "/{tail:.*}", proxy) - runner = web.AppRunner(app) - await runner.setup() - await web.TCPSite(runner, "0.0.0.0", PORT).start() - print(f"proxy: :{PORT} -> {', '.join(UPSTREAMS)} routing={routing.ROUTING} " - f"key={routing.AFFINITY_BYTES}B known_max={routing.KNOWN_MAX}", flush=True) - watcher = asyncio.create_task(watch_health()) - with suppress(asyncio.CancelledError): - await asyncio.gather(watcher) - - -if __name__ == "__main__": - with suppress(KeyboardInterrupt): - asyncio.run(main()) diff --git a/dankserver/.AGENTTRASH/20260916-vllm-radiance/frank-medium-a.container b/dankserver/.AGENTTRASH/20260916-vllm-radiance/frank-medium-a.container deleted file mode 100644 index 4f121cc..0000000 --- a/dankserver/.AGENTTRASH/20260916-vllm-radiance/frank-medium-a.container +++ /dev/null @@ -1,98 +0,0 @@ -# Quadlet unit: frank-medium replica A (rootless, boot-persistent). -# -# Install path on dankserver: ~/.config/containers/systemd/frank-medium-a.container -# Unit name: frank-medium-a.service (container name: frank-medium-a) -# -# WHY TWO REPLICAS INSTEAD OF TP=2. The intended shape for frank-medium is one -# tensor-parallel process across both R9700s (see frank-medium.container, kept -# as the target). That shape cannot run on this host: RCCL's collective kernels -# carry a hidden hostcall-buffer parameter, and the ROCr runtime refuses to -# dispatch them on a device without PCIe atomics ("Pcie atomics not enabled, -# hostcall not supported" -> AQL dispatch failed -> hipErrorIllegalState). The -# second R9700 sits behind the chipset bridges 05:00.0/06:09.0, which advertise -# `AtomicOpsCap: Routing-` — a read-only capability, so no software can enable -# atomics on that path; the amdgpu driver's pci_enable_atomic_ops_to_root() -# therefore fails for that GPU and KFD marks the CPU<->GPU2 link NO_ATOMICS. -# The first R9700 sits on CPU root port 00:01.1 (Routing+) and does have -# atomics. Result: every collective that touches GPU 2 dies at launch. -# Two independent single-GPU replicas use both cards instead; the proxy unit -# keeps the single :7770 endpoint. Full evidence: the frank-medium runbook. -# -# SHAPE. vLLM binds 0.0.0.0:7771 on the host network directly; no PublishPort. -# MAXLEN is 114688, not the TP=2 deployment's 262144: one card carries the -# whole model (~20 GiB of weights + drafter), leaving ~5.2 GiB of KV, and -# 131072 does not fit (measured: needs 5.58 GiB). KV_MEM=0 lets vLLM profile -# this shape instead of using the 2x7551-32624 pin, which is only valid at -# TP=2/262144. -# -# DEVICES. /dev/kfd + /dev/dri; HIP_VISIBLE_DEVICES=0 (the first R9700). The -# 2 GiB Raphael iGPU enumerates as index 2 and is excluded by the pin. -# Only HIP_VISIBLE_DEVICES is set, never ROCR_VISIBLE_DEVICES: ROCR filters -# first and HIP indexes into the filtered list, so a single-valued pair -# (ROCR=1,HIP=1) leaves an empty device set. -# -# DO NOT name an environment variable TEMP, HOST or GPU_MAX_HEAP_SIZE: the -# HIP runtime reads bare TEMP for its JIT cache path (2026-08-13 incident). -[Unit] -Description=frank-medium replica A — vllm-radiance MXFP4 Qwen3.8-27B on R9700 #1 -After=network-online.target - -[Container] -ContainerName=frank-medium-a -Image=localhost/radiance-vllm:0.9.3 -AddDevice=/dev/kfd -AddDevice=/dev/dri -GroupAdd=keep-groups -# The upstream launcher's exact podman posture (rootless). -PodmanArgs=--privileged --ipc=host --security-opt seccomp=unconfined --cap-add SYS_PTRACE --network=host -Environment=HIP_VISIBLE_DEVICES=0 -Environment=SNAP=/models/Qwen3.8-27B-Uncensored-MXFP4-awq -Environment=DRAFTER=/models/Qwen3.8-27B-DFlash2-FP8 -Environment=CHAT_TEMPLATE=/patches/qwen-fixed-v22.3.jinja -Environment=SERVED_NAMES=frank-medium -Environment=PORT=7771 -Environment=TP=1 -Environment=MAXSEQS=4 -Environment=CHUNK=8192 -Environment=MAXLEN=114688 -Environment=GPU_UTIL=0.98 -Environment=KV_MEM=0 -Environment=SPEC_METHOD=dflash -Environment=SPEC=7 -# Checkpoints (rw: nothing downloads at runtime, but vLLM mmaps the shards). -Volume=/home/dank/models:/models -# Tokenizer files ride the HF cache; HF_HUB_OFFLINE=1 is set in the entrypoint. -Volume=/home/dank/.cache/huggingface:/root/.cache/huggingface -# Compile cache. Shared with the other replica and with the TP=2 target: the -# entries are content-addressed, so shapes simply add subdirectories. -Volume=/home/dank/.radiance-cache-w4a8-093-gdnm-nqft-fp8s-gnq:/cache -# The pinned radiance clone: patches applied at every container start. -Volume=/home/dank/GitPackages/radiance-vllm-mxfp4:/patches:z -# The patched libr4d (pin b9e42ab + repo extras, key b9e42ab-rx6). The image's -# shipped r4d.so predates the GDN overflow fix and NaNs this model. -Volume=/home/dank/.cache/radiance-libr4d/b9e42ab-rx6:/r4d:z -# Entrypoint + healthcheck from this checkout, mounted read-only. -Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/radiance-entrypoint.sh:/usr/local/bin/frank-medium-entrypoint.sh:ro -Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/frank-medium-healthcheck.py:/usr/local/bin/frank-medium-healthcheck.py:ro -# The entrypoint must be the container command; the image's own entrypoint -# (which the patched script execs at the end) must not run unpatched. -PodmanArgs=--entrypoint /usr/local/bin/frank-medium-entrypoint.sh -# 200 only once the engine serves; during first-start compilation the -# container is legitimately unhealthy. HealthCmd is split on spaces into an -# argv array by the podman 5.x v1 generator, hence the file-based probe. -HealthCmd=python3 /usr/local/bin/frank-medium-healthcheck.py -HealthInterval=60s -HealthTimeout=30s -HealthRetries=3 -HealthStartPeriod=1800s -Pull=never - -[Service] -# First start compiles Triton/inductor kernels (several minutes) before the -# engine loads ~20 GiB of weights. -TimeoutStartSec=1800 -Restart=on-failure -RestartSec=10 - -[Install] -WantedBy=default.target diff --git a/dankserver/.AGENTTRASH/20260916-vllm-radiance/frank-medium-b.container b/dankserver/.AGENTTRASH/20260916-vllm-radiance/frank-medium-b.container deleted file mode 100644 index 4839383..0000000 --- a/dankserver/.AGENTTRASH/20260916-vllm-radiance/frank-medium-b.container +++ /dev/null @@ -1,98 +0,0 @@ -# Quadlet unit: frank-medium replica B (rootless, boot-persistent). -# -# Install path on dankserver: ~/.config/containers/systemd/frank-medium-b.container -# Unit name: frank-medium-b.service (container name: frank-medium-a) -# -# WHY TWO REPLICAS INSTEAD OF TP=2. The intended shape for frank-medium is one -# tensor-parallel process across both R9700s (see frank-medium.container, kept -# as the target). That shape cannot run on this host: RCCL's collective kernels -# carry a hidden hostcall-buffer parameter, and the ROCr runtime refuses to -# dispatch them on a device without PCIe atomics ("Pcie atomics not enabled, -# hostcall not supported" -> AQL dispatch failed -> hipErrorIllegalState). The -# second R9700 sits behind the chipset bridges 05:00.0/06:09.0, which advertise -# `AtomicOpsCap: Routing-` — a read-only capability, so no software can enable -# atomics on that path; the amdgpu driver's pci_enable_atomic_ops_to_root() -# therefore fails for that GPU and KFD marks the CPU<->GPU2 link NO_ATOMICS. -# The first R9700 sits on CPU root port 00:01.1 (Routing+) and does have -# atomics. Result: every collective that touches GPU 2 dies at launch. -# Two independent single-GPU replicas use both cards instead; the proxy unit -# keeps the single :7770 endpoint. Full evidence: the frank-medium runbook. -# -# SHAPE. vLLM binds 0.0.0.0:7772 on the host network directly; no PublishPort. -# MAXLEN is 114688, not the TP=2 deployment's 262144: one card carries the -# whole model (~20 GiB of weights + drafter), leaving ~5.2 GiB of KV, and -# 131072 does not fit (measured: needs 5.58 GiB). KV_MEM=0 lets vLLM profile -# this shape instead of using the 2x7551-32624 pin, which is only valid at -# TP=2/262144. -# -# DEVICES. /dev/kfd + /dev/dri; HIP_VISIBLE_DEVICES=1 (the second R9700). The -# 2 GiB Raphael iGPU enumerates as index 2 and is excluded by the pin. -# Only HIP_VISIBLE_DEVICES is set, never ROCR_VISIBLE_DEVICES: ROCR filters -# first and HIP indexes into the filtered list, so a single-valued pair -# (ROCR=1,HIP=1) leaves an empty device set. -# -# DO NOT name an environment variable TEMP, HOST or GPU_MAX_HEAP_SIZE: the -# HIP runtime reads bare TEMP for its JIT cache path (2026-08-13 incident). -[Unit] -Description=frank-medium replica B — vllm-radiance MXFP4 Qwen3.8-27B on R9700 #2 -After=network-online.target - -[Container] -ContainerName=frank-medium-b -Image=localhost/radiance-vllm:0.9.3 -AddDevice=/dev/kfd -AddDevice=/dev/dri -GroupAdd=keep-groups -# The upstream launcher's exact podman posture (rootless). -PodmanArgs=--privileged --ipc=host --security-opt seccomp=unconfined --cap-add SYS_PTRACE --network=host -Environment=HIP_VISIBLE_DEVICES=1 -Environment=SNAP=/models/Qwen3.8-27B-Uncensored-MXFP4-awq -Environment=DRAFTER=/models/Qwen3.8-27B-DFlash2-FP8 -Environment=CHAT_TEMPLATE=/patches/qwen-fixed-v22.3.jinja -Environment=SERVED_NAMES=frank-medium -Environment=PORT=7772 -Environment=TP=1 -Environment=MAXSEQS=4 -Environment=CHUNK=8192 -Environment=MAXLEN=114688 -Environment=GPU_UTIL=0.98 -Environment=KV_MEM=0 -Environment=SPEC_METHOD=dflash -Environment=SPEC=7 -# Checkpoints (rw: nothing downloads at runtime, but vLLM mmaps the shards). -Volume=/home/dank/models:/models -# Tokenizer files ride the HF cache; HF_HUB_OFFLINE=1 is set in the entrypoint. -Volume=/home/dank/.cache/huggingface:/root/.cache/huggingface -# Compile cache. Shared with the other replica and with the TP=2 target: the -# entries are content-addressed, so shapes simply add subdirectories. -Volume=/home/dank/.radiance-cache-w4a8-093-gdnm-nqft-fp8s-gnq:/cache -# The pinned radiance clone: patches applied at every container start. -Volume=/home/dank/GitPackages/radiance-vllm-mxfp4:/patches:z -# The patched libr4d (pin b9e42ab + repo extras, key b9e42ab-rx6). The image's -# shipped r4d.so predates the GDN overflow fix and NaNs this model. -Volume=/home/dank/.cache/radiance-libr4d/b9e42ab-rx6:/r4d:z -# Entrypoint + healthcheck from this checkout, mounted read-only. -Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/radiance-entrypoint.sh:/usr/local/bin/frank-medium-entrypoint.sh:ro -Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/frank-medium-healthcheck.py:/usr/local/bin/frank-medium-healthcheck.py:ro -# The entrypoint must be the container command; the image's own entrypoint -# (which the patched script execs at the end) must not run unpatched. -PodmanArgs=--entrypoint /usr/local/bin/frank-medium-entrypoint.sh -# 200 only once the engine serves; during first-start compilation the -# container is legitimately unhealthy. HealthCmd is split on spaces into an -# argv array by the podman 5.x v1 generator, hence the file-based probe. -HealthCmd=python3 /usr/local/bin/frank-medium-healthcheck.py -HealthInterval=60s -HealthTimeout=30s -HealthRetries=3 -HealthStartPeriod=1800s -Pull=never - -[Service] -# First start compiles Triton/inductor kernels (several minutes) before the -# engine loads ~20 GiB of weights. -TimeoutStartSec=1800 -Restart=on-failure -RestartSec=10 - -[Install] -WantedBy=default.target diff --git a/dankserver/.AGENTTRASH/20260916-vllm-radiance/frank-medium-proxy.container b/dankserver/.AGENTTRASH/20260916-vllm-radiance/frank-medium-proxy.container deleted file mode 100644 index 0fe4f8f..0000000 --- a/dankserver/.AGENTTRASH/20260916-vllm-radiance/frank-medium-proxy.container +++ /dev/null @@ -1,61 +0,0 @@ -# Quadlet unit: frank-medium proxy (rootless, boot-persistent). -# -# Install path on dankserver: ~/.config/containers/systemd/frank-medium-proxy.container -# Unit name: frank-medium-proxy.service (container name: frank-medium-proxy) -# -# WHAT IT IS. A prefix-affinity reverse proxy in front of the two single-GPU -# frank-medium replicas (:7771 on R9700 #1, :7772 on R9700 #2). It preserves -# the fleet contract: one endpoint on host port 7770 answering as the served -# model name frank-medium, OpenAI /v1, streaming included. -# -# WHY IT EXISTS. TP=2 — one vLLM process spanning both cards — is impossible -# on this host (see frank-medium-a.container and the runbook: RCCL's kernels -# need PCIe atomics, and the second card's chipset path advertises -# AtomicOpsCap: Routing-). Two replicas are how both cards get used. -# -# ROUTING. Requests are keyed on a hash of their leading 4 KiB of prompt text, -# so a conversation family — an agent loop re-sending the same system prompt, -# tool list and file context every turn — keeps landing on the same replica, -# where --enable-prefix-caching has that prefix warm. Plain round-robin threw -# half of those cache hits away. A replica that is down is skipped, and -# ROUTING=roundrobin restores the old behaviour for A/B measurement. /health -# answers 200 while at least one replica is up and names the ones that are -# down. The proxy runs on host networking so it can bind 7770 and reach the -# replicas on loopback. -# -# The script is a few dozen lines of aiohttp, mounted read-only out of -# /home/dank/serverAppConfigs (deploy.sh pulls that checkout before dispatch). -# It reuses the same image as the replicas: aiohttp is already there and no -# extra image has to be pulled on this host. -[Unit] -Description=frank-medium proxy — prefix-affinity in front of the two single-GPU replicas -After=network-online.target -Wants=frank-medium-a.service frank-medium-b.service -After=frank-medium-a.service frank-medium-b.service - -[Container] -ContainerName=frank-medium-proxy -Image=localhost/radiance-vllm:0.9.3 -PodmanArgs=--network=host -Environment=PORT=7770 -Environment=UPSTREAMS=http://127.0.0.1:7771,http://127.0.0.1:7772 -# The proxy is two files: routing.py holds the replica-selection policy, the -# server holds the HTTP plumbing. Both are mounted because the server imports -# the policy by name from its own directory (/usr/local/bin is sys.path[0]). -Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/proxy/routing.py:/usr/local/bin/routing.py:ro -Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/proxy/affinity-proxy.py:/usr/local/bin/affinity-proxy.py:ro -Volume=/home/dank/serverAppConfigs/dankserver/frank-medium/frank-medium-healthcheck.py:/usr/local/bin/frank-medium-healthcheck.py:ro -PodmanArgs=--entrypoint /usr/local/bin/affinity-proxy.py -HealthCmd=python3 /usr/local/bin/frank-medium-healthcheck.py -HealthInterval=60s -HealthTimeout=30s -HealthRetries=3 -HealthStartPeriod=60s -Pull=never - -[Service] -Restart=on-failure -RestartSec=10 - -[Install] -WantedBy=default.target diff --git a/dankserver/.AGENTTRASH/20260916-vllm-radiance/routing.py b/dankserver/.AGENTTRASH/20260916-vllm-radiance/routing.py deleted file mode 100755 index e5b38b0..0000000 --- a/dankserver/.AGENTTRASH/20260916-vllm-radiance/routing.py +++ /dev/null @@ -1,129 +0,0 @@ -#!/usr/bin/env python3 -"""Routing policy for the frank-medium proxy: which replica serves a request. - -Kept apart from the HTTP plumbing in affinity-proxy.py so the decisions can be -tested without aiohttp, a network or a GPU — the first version of these rules -shipped with a bug that only appeared on a continuation request, and a test -that loads this module catches that class of mistake directly. - -Two cards, two behaviours, and the workload decides which is right: - -* A **single-turn** request is one member of a batch (a benchmark run, a burst - of parallel subagents). Spreading those over both replicas lets both cards - prefill at once; measured on this host, a 16-request burst of 15k-token - prompts finishes in 18.6 s spread versus 30.1 s pinned. -* A **continuation** re-sends a prefix the replica has already prefilled. Pinned - to that replica the turn costs a cache lookup; spread, half the turns pay a - cold prefill again (measured: 1096 ms versus 2676 ms mean warm-turn TTFT on - 90k-token contexts). - -ROUTING picks the strategy; the default `adaptive` applies both rules above. -`affinity` and `roundrobin` exist so the choice can be re-measured, and are not -production settings. -""" - -from __future__ import annotations - -import collections -import hashlib -import itertools -import json -import os - -UPSTREAMS = [u.strip() for u in os.environ.get( - "UPSTREAMS", "http://127.0.0.1:7771,http://127.0.0.1:7772").split(",") if u.strip()] -ROUTING = os.environ.get("ROUTING", "adaptive").strip().lower() -# Leading bytes of prompt text that identify a conversation's shared prefix. -# 4 KiB covers a system prompt plus the start of the first user turn, which is -# where agent loops keep the text they resend every turn. -AFFINITY_BYTES = int(os.environ.get("AFFINITY_BYTES", "4096")) -# Prefixes remembered for pinning. Bounded so a long-lived proxy cannot grow -# without limit; 4096 conversations is far beyond what this fleet runs at once. -KNOWN_MAX = int(os.environ.get("KNOWN_MAX", "4096")) - -_counter = itertools.count() -# digest -> replica index, insertion-ordered for O(1) LRU eviction. A prefix is -# remembered as soon as any request carries it; only a continuation is pinned -# there, because only a continuation can profit from the cache entry. -_known: "collections.OrderedDict[bytes, int]" = collections.OrderedDict() - - -def affinity_key(body: bytes, remote: str | None) -> tuple[bytes, bool]: - """Key a request by its shared prefix, and report whether it continues one. - - Chat and completions bodies both carry their prompt text in JSON, so the - leading bytes of that text are the prefix; a body we cannot parse (or an - endpoint with no prompt, e.g. /v1/models) falls back to the client address, - which still beats nothing for a single-client loop. - """ - fallback = (remote or "anonymous").encode() - try: - payload = json.loads(body) - except Exception: - return fallback, False - - text = "" - continuation = False - if isinstance(payload, dict): - if isinstance(payload.get("prompt"), str): - text = payload["prompt"] - elif isinstance(payload.get("messages"), list): - continuation = len(payload["messages"]) > 1 - parts = [] - for msg in payload["messages"]: - if isinstance(msg, dict): - content = msg.get("content") - if isinstance(content, str): - parts.append(content) - elif isinstance(content, list): # multimodal content blocks - parts.extend( - blk.get("text", "") for blk in content - if isinstance(blk, dict) and isinstance(blk.get("text"), str) - ) - text = "\n".join(parts) - if not text: - return fallback, continuation - return text[:AFFINITY_BYTES].encode("utf-8", "replace"), continuation - - -def pick(key: bytes, continuation: bool, up: list[bool]) -> int: - """Replica for a request, or -1 when none is available. - - Down replicas are never selected — a rollout must not send traffic to an - engine that is still compiling its graphs — and a pin whose replica is down - is re-established on a live one rather than kept pointing at a corpse. - """ - digest = hashlib.sha1(key).digest() - - if ROUTING == "roundrobin": - start = next(_counter) % len(UPSTREAMS) - elif ROUTING == "adaptive": - entry = _known.get(digest) - if continuation and entry is not None and up[entry]: - _known.move_to_end(digest) - return entry - # Anything that is not a continuation spreads by counter — including a - # batch of requests that happen to share one prefix. Hashing the prefix - # would send every member of such a batch to whichever card the hash - # names (measured: 29.8 s for a 16-request burst, against 18.6 s when - # spread). The counter is only advanced by these requests, so batch - # members alternate across the cards. - start = next(_counter) % len(UPSTREAMS) - else: # affinity - start = int.from_bytes(digest[:8], "big") % len(UPSTREAMS) - - for offset in range(len(UPSTREAMS)): - idx = (start + offset) % len(UPSTREAMS) - if up[idx]: - if ROUTING == "adaptive": - _known[digest] = idx - _known.move_to_end(digest) - if len(_known) > KNOWN_MAX: - _known.popitem(last=False) - return idx - return -1 - - -def route(body: bytes, remote: str | None, up: list[bool]) -> int: - """Convenience wrapper: decode the request body and choose a replica.""" - return pick(*affinity_key(body, remote), up) diff --git a/scripts/.AGENTTRASH/20260920-llama-layersplit/bench-spec-ab.py.llamacpp-layersplit b/scripts/.AGENTTRASH/20260920-llama-layersplit/bench-spec-ab.py.llamacpp-layersplit deleted file mode 100755 index e453cc9..0000000 --- a/scripts/.AGENTTRASH/20260920-llama-layersplit/bench-spec-ab.py.llamacpp-layersplit +++ /dev/null @@ -1,309 +0,0 @@ -#!/usr/bin/env python3 -"""bench-spec-ab.py — settle "which proposer should this endpoint use?" with data. - -Runs one payload through several speculative-decoding configurations of the same -binary, one at a time, with the unit stopped between runs: on a single-GPU host -there is no room for two copies of a 29 GiB model, and the benchmarking policy -applies. Every configuration gets identical prompts at temperature 0, and the -greedy text is compared across them — speculation may buy speed, never change -the answer. - - bench-spec-ab.py <env-file> <serve-script> [options] - bench-spec-ab.py /home/dank/serverAppConfigs/scripts/frank-medium.env \ - /home/dank/serverAppConfigs/scripts/serve-frank-medium.sh \ - --configs "dflash=draft-dflash@7;ngram=ngram-mod@64;chain=draft-dflash,ngram-mod@7" - -Config syntax: `name=SPEC_TYPE[@n_max][|ENV=VAL|ENV=VAL]`, entries joined by `;`. -The systemd unit is restored in a finally block, so it comes back on whatever the -env file says even if a configuration crashes the harness. - -THREE SHAPES, because a single one answers the wrong question. A proposer can -only draft text it has seen, so the shape decides the verdict: - - prose a 1k prompt asking for one sentence. Nothing to copy and nothing - repetitive, so only a model-backed proposer can help: this is the - shape that compares drafters. - echo a ~1.5k passage of unique lines, "repeat it back verbatim". The - output IS the prompt, which is ngram-mod's home turf. - structured "25 JSON records, one per line" — repetitive OUTPUT with no prompt - overlap, which is what an ngram matcher is really for in an - agentic fleet (config edits, tables, record lists). - -echo and structured disable thinking (`chat_template_kwargs`), otherwise the -model thinks the answer into `reasoning_content`, `content` comes back empty and -the cross-configuration identity check passes vacuously — which is exactly how -this harness first "verified" an echo it never looked at. - -Results on Dankserver (frank-medium payload, 2026-09-16, temperature 0). Decode: - - shape dflash=7 ngram-mod(48/64) chain (tok/s) - prose 1k/512 43.8 17.7 (0 drafts) 43.7 - echo 1.5k/400 76.8 102.5 200.6 - structured/400 91.1 17.8 (0 drafts) 91.2 - identity 70.4 17.8 (0 drafts) 70.4 - -`draft-mtp,draft-dflash` fails at load (a chain has ONE draft context and MTP -builds against whatever model that context holds); ngram-mod has no such -constraint — it is draftless and joins the chain unconditionally. -""" -import argparse -import json -import os -import re -import subprocess -import sys -import time -import urllib.request - -FILLER = ("The quick brown fox jumps over the lazy dog near the river bank. " - "A cache is a component that stores data so future requests are faster. ") -TOKENS_PER_REP = 28 -UNIT = "frank-medium.service" -MODEL = "frank-medium" -SPEC_GREP = ("adding speculative implementation|- n_max=|- block_size=|creating MTP|" - "draft acceptance") - -DEFAULT_CONFIGS = [ - ("none", {"SPEC_TYPE": "none"}), - ("mtp-n3", {"SPEC_TYPE": "draft-mtp", "SPEC_DRAFT_N_MAX": "3"}), - ("mtp-n7", {"SPEC_TYPE": "draft-mtp", "SPEC_DRAFT_N_MAX": "7"}), - ("dflash-n7", {"SPEC_TYPE": "draft-dflash", "SPEC_DRAFT_N_MAX": "7"}), -] - -WORDS = ["latency", "throughput", "cache", "adapter", "scheduler", "allocator", - "queue", "kernel", "buffer", "shard", "replica", "window"] - - -def echo_passage(lines=90): - """Unique lines: no long span repeats inside the prompt, so an n-gram can - only fire when the OUTPUT copies the INPUT.""" - return "\n".join( - "Record %03d: the %s of subsystem %d reports state %d with checksum %05d." - % (i, WORDS[i % len(WORDS)], i % 7, i % 3, (i * 7919) % 100000) - for i in range(lines)) - - -ECHO = (echo_passage() + "\n\nRepeat the passage above verbatim, every line, exactly as " - "written, with no commentary before or after it.") -STRUCTURED = ("Output exactly 25 JSON records, one per line, no prose and no code fence. " - 'Each line must look like {"id": N, "state": "active", "region": "us-west-2"} ' - "with N running from 1 to 25.") -PROSE = FILLER * (1000 // TOKENS_PER_REP) + "\n\nReply with a single short sentence." -IDENTITY = "List the first five prime numbers, comma separated, then stop." -NO_THINK = {"chat_template_kwargs": {"enable_thinking": False}} - - -def log(msg, logfile): - line = "[" + time.strftime("%H:%M:%S") + "] " + str(msg) - print(line, flush=True) - with open(logfile, "a") as fh: - fh.write(line + "\n") - - -def sh(cmd): - return subprocess.run(cmd, shell=True, capture_output=True, text=True).stdout.strip() - - -def parse_configs(spec): - """`name=SPEC_TYPE[@n_max][|KEY=VALUE]...`, entries joined by ';'. - - KEY=VALUE parts override any env file key, which is how an impl's own window - (SPEC_NGRAM_N_MIN/MAX, for instance) gets swept without hand-editing env - files.""" - out = [] - for chunk in spec.split(";"): - chunk = chunk.strip() - if not chunk: - continue - name, _, rest = chunk.partition("=") - over = {} - if "|" in rest: - rest, _, extras = rest.partition("|") - for item in extras.split("|"): - if "=" in item: - k, _, v = item.partition("=") - over[k.strip()] = v.strip() - n_max = None - if "@" in rest: - rest, _, n_max = rest.partition("@") - over["SPEC_TYPE"] = rest.strip() - if n_max: - over["SPEC_DRAFT_N_MAX"] = n_max.strip() - out.append((name.strip(), over)) - return out - - -def write_env(src, name, overrides): - """The env file with this configuration's overrides applied.""" - path = "/tmp/spec-ab-" + name.replace("/", "_") + ".env" - with open(src) as fh: - lines = fh.read().splitlines() - out = [] - for line in lines: - for k, v in overrides.items(): - if line.startswith(k + "="): - line = k + "=" + v - break - out.append(line) - with open(path, "w") as fh: - fh.write("\n".join(out) + "\n") - return path - - -def post(base, body, timeout=1800): - req = urllib.request.Request( - base + "/v1/chat/completions", data=json.dumps(body).encode(), - headers={"Content-Type": "application/json"}) - with urllib.request.urlopen(req, timeout=timeout) as r: - return json.load(r) - - -def vram(): - out = sh("rocm-smi --showmeminfo vram 2>/dev/null | grep 'Used Memory'") - return [round(int(x) / 1e9, 2) for x in re.findall(r":\s+(\d+)\s*$", out, re.M)][:2] - - -def wait_health(base, timeout=420): - t0 = time.time() - while time.time() - t0 < timeout: - if not sh("pgrep -x llama-server >/dev/null && echo up"): - return False - try: - with urllib.request.urlopen(base + "/health", timeout=5) as r: - if r.status == 200 and json.load(r).get("status") == "ok": - return True - except Exception: - time.sleep(3) - return False - - -def stop_unit(): - sh("sudo systemctl stop " + UNIT) - sh("pkill -x llama-server") - for _ in range(40): - if not sh("pgrep -x llama-server || true"): - return - time.sleep(1) - - -def restore_unit(base, logfile): - """Bring the endpoint back on the deployed configuration. Runs in a - finally, because a harness that can leave production down is a hazard.""" - log("restoring " + UNIT, logfile) - sh("sudo systemctl start " + UNIT) - for _ in range(120): - try: - with urllib.request.urlopen(base + "/health", timeout=5) as r: - if json.load(r).get("status") == "ok": - log(UNIT + " healthy again", logfile) - return True - except Exception: - time.sleep(5) - log("WARNING: " + UNIT + " did not come back healthy -- check it by hand", logfile) - return False - - -def run_shape(base, prompt, max_tokens, label, extra=None): - body = {"model": MODEL, "messages": [{"role": "user", "content": prompt}], - "max_tokens": max_tokens, "temperature": 0.0} - if extra: - body.update(extra) - d = post(base, body) - tm = d["timings"] - msg = d["choices"][0]["message"] - content = (msg.get("content") or "").strip() - row = {"shape": label, - "prompt_n": tm["prompt_n"], - "prefill_tps": round(1000 * tm["prompt_n"] / tm["prompt_ms"], 1), - "predicted_n": tm["predicted_n"], - "decode_tps": round(tm["predicted_per_second"], 2), - "draft_n": tm.get("draft_n", 0), - "accepted": tm.get("draft_n_accepted", 0), - "answer": content, - "reasoning_len": len(msg.get("reasoning_content") or "")} - if label.startswith("echo"): - # How much of the answer really is the passage: a configuration that - # "wins" by answering something else is not a win. - want = {l.strip() for l in echo_passage().splitlines()} - got = [l.strip() for l in content.splitlines() if l.strip()] - row["copy_ratio"] = round(sum(1 for l in got if l in want) / len(got), 3) if got else 0.0 - return row - - -def measure(base): - run_shape(base, FILLER * (4000 // TOKENS_PER_REP) + - "\n\nReply with a single short sentence.", 32, "warmup") - return [ - run_shape(base, PROSE, 512, "prose 1k/512"), - run_shape(base, ECHO, 400, "echo 1.5k/400", NO_THINK), - run_shape(base, STRUCTURED, 400, "structured/400", NO_THINK), - run_shape(base, IDENTITY, 192, "identity"), - ] - - -def main(): - ap = argparse.ArgumentParser() - ap.add_argument("env_file") - ap.add_argument("serve_script") - ap.add_argument("--base-url", default="http://127.0.0.1:7770") - ap.add_argument("--log", default="/tmp/bench-spec-ab.log") - ap.add_argument("--configs", default=None, - help="name=SPEC_TYPE[@n_max][|ENV=VAL] joined by ';' " - "(default: the four configs measured on 2026-09-16)") - ap.add_argument("--json", default="/tmp/bench-spec-ab.json") - args = ap.parse_args() - - configs = parse_configs(args.configs) if args.configs else DEFAULT_CONFIGS - # Absolute paths: the launcher must not depend on the caller's cwd, and a - # relative path plus a `cd` to its own directory double-counts the prefix. - env_file = os.path.abspath(args.env_file) - serve_script = os.path.abspath(args.serve_script) - base, logfile = args.base_url, args.log - results = {} - log("spec A/B start: " + ", ".join(n for n, _ in configs) + - " (unit " + UNIT + " stopped for the duration)", logfile) - try: - stop_unit() - for name, over in configs: - env_path = write_env(env_file, name, over) - logf = "/tmp/spec-ab-" + name.replace("/", "_") + ".log" - sh("FRANK_MEDIUM_ENV_FILE=" + env_path + " setsid bash " + serve_script + - " > " + logf + " 2>&1 < /dev/null &") - if not wait_health(base): - log(name + ": FAILED to become healthy -- " + sh("tail -3 " + logf), logfile) - results[name] = {"error": "not healthy", "tail": sh("tail -6 " + logf)} - stop_unit() - continue - rows = measure(base) - results[name] = {"vram_gb": vram(), "rows": rows, - "spec_log": sh("grep -E '" + SPEC_GREP + "' " + logf + " | tail -4")} - for r in rows: - log(name + " | " + r["shape"] + " prefill " + str(r["prefill_tps"]) + - " tok/s | decode " + str(r["decode_tps"]) + " tok/s | draft " + - str(r["draft_n"]) + "/" + str(r["accepted"]) + - (" (" + str(round(100.0 * r["accepted"] / r["draft_n"])) + " %)" - if r["draft_n"] else "") + - (" | copy " + str(r["copy_ratio"]) if "copy_ratio" in r else "") + - (" | EMPTY ANSWER" if not r["answer"] else ""), logfile) - log(name + " | vram " + str(results[name]["vram_gb"]), logfile) - stop_unit() - finally: - restore_unit(base, logfile) - - with open(args.json, "w") as fh: - json.dump(results, fh, indent=2) - for shape in ("prose 1k/512", "echo 1.5k/400", "structured/400", "identity"): - texts = [r["answer"] for v in results.values() if "rows" in v - for r in v["rows"] if r["shape"] == shape] - if any(not t for t in texts): - log("WARNING: '" + shape + "' produced an empty answer in some configuration -- " - "its identity check proves nothing", logfile) - elif len(set(texts)) > 1: - log("WARNING: configurations disagree on greedy text for '" + shape + - "' -- speculation is changing answers", logfile) - log("done; results in " + args.json, logfile) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file diff --git a/scripts/.AGENTTRASH/20260920-llama-layersplit/frank-medium.env.llamacpp-layersplit b/scripts/.AGENTTRASH/20260920-llama-layersplit/frank-medium.env.llamacpp-layersplit deleted file mode 100644 index f41afe8..0000000 --- a/scripts/.AGENTTRASH/20260920-llama-layersplit/frank-medium.env.llamacpp-layersplit +++ /dev/null @@ -1,318 +0,0 @@ -# Tunables for frank-medium (orcarouter/Qwen3.8-27B-Uncensored-GGUF Q8_0 + -# z-lab/Qwen3.8-27B-DFlash2-GGUF Q4_K_M drafter), mainline llama.cpp on -# Dankserver, 2x AMD Radeon AI PRO R9700 (gfx1201). Since 2026-09-16 this is -# the SOLE LLM runtime on the box: the vllm-radiance replica pair and its -# routing proxy are retired. -# -# Hardware: two R9700 (Navi 48, RDNA4, 32,624 MiB each), no XGMI, different -# IOMMU groups -- every cross-card byte goes over PCIe. The Raphael iGPU -# (gfx1036/Radeon 610M) enumerates as HIP device 2 and is hidden below so -# nothing can drift onto shared system RAM. -# -# WHY LAYER SPLIT AND NOT TENSOR (operator call, 2026-09-16): tensor parallel -# needs a cross-card allreduce per layer, and this host cannot dispatch one. -# RCCL's kernels carry a hidden hostcall-buffer parameter, and ROCr refuses it -# on a device without PCIe atomics -- the second card sits behind the B650 -# chipset bridges (AtomicOpsCap: Routing-, read-only), so every collective that -# touches it dies with hipErrorIllegalState. Measured on this box 2026-09-15 -# (llama-bench, pp4096 / tg128, f16 KV, fa on): -# -# single R9700 1160.9 / 19.42 -# layer 2x 1991.2 / 18.76 (859.1 / 17.64 at depth 32768) -# tensor 2x 271.8 / 26.26 (no RCCL: butterfly fallback, -7x prefill) -# -# Layer split distributes layers in order; only the activation at the boundary -# crosses the wire, once per forward pass. That is the "pipeline parallelism" -# this service wanted, and it needs no collective at all. -# -# Runtime: mainline llama.cpp master fb27a525 (2026-09-16), gfx1201, built by -# scripts/build-llama-rdna4.sh against a private ROCm 7.14.0a20260624 toolchain -# that is bundled next to the binary (RPATH $ORIGIN/../lib, no LD_LIBRARY_PATH). -# The ref is pinned by TWO features: DFlash2 (ggml-org/llama.cpp#27342 -- the -# drafter is detected from its GGUF) and qwen35 MTP. Do not float it. -# -# Model: orcarouter/Qwen3.8-27B-Uncensored-GGUF @ Q8_0, 27.0 GiB, arch qwen35 -# (verified from the GGUF header 2026-09-19: 262144 trained window, 24 heads / -# 4 KV heads, nextn_predict_layers=1). The file ALSO carries the model's MTP -# head (blk.64.nextn.*), so --spec-type draft-mtp is available without a second -# copy in VRAM -- one SPEC_TYPE change, the drafter below stays on disk. -# -# Drafter: z-lab/Qwen3.8-27B-DFlash2-GGUF @ Q4_K_M, 1.06 GiB (operator call -# 2026-09-19: the Q4 rung -- the drafter's job is throughput, not output -# quality, and the target verifies every drafted token). 5 layers, hidden -# 5120, a block-diffusion proposal (block_size 8) under a 2048-token sliding -# window (dflash.attention.sliding_window=2048, verified from the header). -# The window is what makes it cheap HERE: its KV is bounded at 2048 positions -# (~40 MiB) instead of spanning the context (see SIZING). -# -# Architecture (GGUF arch qwen35): 64 layers as 16 x (3 x (Gated DeltaNet -> -# FFN) + 1 x (Gated Attention -> FFN)) -- 48 linear-attention layers whose -# per-sequence state is CONSTANT-SIZE, 16 full-attention layers at 4 KV heads x -# 256 head_dim. A token therefore costs 2 x 4 x 256 x 2 B = 64 KiB of KV at f16 -# in the attention layers only, plus ~0.16 GiB per sequence of f32 DeltaNet -# state at any depth. -# -# SIZING, the deployed shape (32:33 / ctx 327680 / 6144x512 / two slots / -# DFlash2 Q4_K_M), measured 2026-09-19 after the model swap (rocm-smi): -# -# card 0 (x16) 26.9 GiB used, 5.7 GiB free -# card 1 (x1) 27.8 GiB used, 4.8 GiB free -# -# The predecessor's measured table (rocm-smi, 2026-09-16, UD-Q8_K_XL target) -# stays as the calibration history: -# -# CTX split batch slots drafter GPU 0 free GPU 1 free note (ubatch 1536) -# 262144 1,1 2048 1 DFlash2 8.64 GiB 4.43 GiB day's start -# 262144 39,26 6144 2 DFlash2 2.42 GiB 7.20 GiB -# 262144 38,27 6144 2 DFlash2 3.85 GiB 5.77 GiB -# 262144 37,28 6144 2 DFlash2 4.28 GiB 5.34 GiB -# 262144 36,29 6144 2 DFlash2 4.71 GiB 4.91 GiB -# 262144 35,30 6144 2 DFlash2 5.14 GiB 4.48 GiB -# 262144 40,25 6144 2 DSpark 1.87 GiB 1.86 GiB (retired) -# -# ...and the same deployed shape re-measured at ubatch 512, after llama-bench -# showed 512 is the throughput optimum and costs 3.71 GiB less in total (see the -# batch block below): -# -# 262144 36,29 6144 512 2 DFlash2 6.01 GiB 7.32 GiB ubatch only -# 262144 35,30 6144 512 2 DFlash2 6.44 GiB 6.90 GiB -# -# ONE -ts STEP IS ONE LAYER, AND LAYERS ARE NOT THE SAME SIZE. This is a hybrid -# stack -- 16 x (3 Gated DeltaNet + 1 Gated Attention) blocks -- so moving a -# DeltaNet layer shifts ~0.43 GiB while an attention layer shifts ~1.43 GiB -# (0.45 of weights plus ~1.0 GiB of its own f16 KV at 262144). Both step sizes -# were measured, which is why the balance is found by measurement rather than by -# interpolating between splits. -# -# WHY THE SPLIT IS 32:33 (operator call, 2026-09-19). The operator asked for -# the tensors BALANCED across the two cards, which for 65 layers is 32:33 -- -# the most even split that exists. The predecessor's 35:30 skew existed to lean -# work onto the x16 card (ROCm0), because ROCm1's Gen4 x1 chipset path made it -# the 98 % utilization bottleneck; the measured trade of the balanced point is -# that the x1 card runs hotter while the x16 card idles a little more, and the -# operator accepted that for symmetry. Do not re-skew without asking. -# -# THE TWO CARDS' PATHS ARE NOT SYMMETRIC (measured, 2026-09-17): ROCm0 -# (03:00.0) hangs off CPU root port 00:01.1 at Gen4 x16 end to end (~31.5 GB/s); -# ROCm1 (0b:00.0) rides the B650 chipset through bridges that each negotiate -# Gen4 x1 (~1.97 GB/s). The layer-split boundary itself is not the cost -# (~5.2 MB of activation per 512-token ubatch rides the wire); the x1 card is -# COMPUTE-bound, not PCIe-bound. -# -# A FAULT TO WATCH. A 39:26 split, at 1x batch / 1 slot, died on its eighth -# request: the amdgpu driver logged SDMA0 UTCL2 page faults and the engine -# surfaced `ROCm error: an illegal memory access was encountered` -> ggml_abort, -# i.e. a fault in the cross-card boundary copy, this layer split's hot path. It -# has not reproduced since, but the signature is worth recognising -- it is NOT -# an OOM, and no amount of headroom prevents it. -# -# WARNING: do not name any variable TEMP, HOST, or GPU_MAX_HEAP_SIZE (HIP reads -# those bare vars; 2026-08-13 incident). llama.cpp takes --host/--port/--temp -# as flags only. - -# llama-server: mainline fb27a525 built for gfx1201 by scripts/build-llama-rdna4.sh. -LLAMA_BIN=/home/dank/GitPackages/llama.cpp-dflash2/build-rdna4-rocm714/bin/llama-server - -# Payload, fetched with scripts/download-frank-medium-gguf.sh. -MODEL_DIR=/home/dank/models/Qwen3.8-27B-Uncensored-GGUF -MODEL_FILE=Qwen3.8-27B-Uncensored-Q8_0.gguf -# The f16 vision projector, same repo as the target. llama-server loads it via -# the explicit --mmproj flag (no autoload in this build); the serve script -# refuses to start without it when vision is enabled, so a missing file is a -# deploy error, not a text-only server that pretends to be multimodal. -MM_PROJ_FILE=mmproj-Qwen3.8-27B-Uncensored-f16.gguf -DRAFT_MODEL_DIR=/home/dank/models/Qwen3.8-27B-DFlash2-GGUF -DRAFT_MODEL_FILE=Qwen3.8-27B-DFlash2-Q4_K_M.gguf - -# Fleet identity. 7770 is the fleet's dankserver endpoint; the alias is the id -# at /v1/models and what every client asks for. -ALIAS=frank-medium -BIND_ADDR=0.0.0.0 -LISTEN_PORT=7770 -# Device placement. HIP_VISIBLE_DEVICES hides the Raphael iGPU (HIP device 2) -# so nothing can drift onto shared system RAM; DEVICES is what llama.cpp splits -# across, and neither of the two cards has an XGMI link to the other. -HIP_VISIBLE_DEVICES=0,1 -DEVICES=ROCm0,ROCm1 -SPLIT_MODE=layer - -# 32:33 -- balanced (operator call 2026-09-19): 65 layers split as evenly as -# they can be, ROCm1 (the last-layers card, which also carries the drafter and -# the output layers) holding the extra one. See the WHY THE SPLIT IS 32:33 -# block above for the trade against the predecessor's 35:30 x16-lean. -TENSOR_SPLIT=32,33 - -NGL=999 -# Auto-fit has no business resizing a context we set explicitly. -FIT=off -FLASH_ATTN=on - -# 327680 positions over TWO slots, so each conversation gets 163840 -- -# --parallel DIVIDES --ctx-size, so more slots would shrink every -# conversation rather than grow the cache. 163840 is inside the checkpoint's -# trained 262144 window, so the whole pool is trained territory (no rope -# extrapolation, unlike the predecessor's 393216). -CTX_SIZE=327680 -PARALLEL=2 - -# MEASURED 2026-09-16 with scripts/bench-batch-ubatch.sh on this box: llama-bench, -# a 64k prefill plus a 128-token decode with 64k of KV already resident, 3 reps, -# one invocation per cell, the deployed split and f16 KV. --ubatch-size is the -# physical batch the graph is built at -- and the only one of the two knobs that -# costs VRAM: -# -# batch ubatch prefill tok/s GPU 0 / GPU 1 free -# 6144 1536 1164.96 +- 2.30 10.28 / 11.71 GiB -# 6144 768 1135.03 +- 1.09 11.29 / 13.28 GiB -# 6144 512 1192.93 +- 0.91 11.63 / 13.81 GiB <- deployed -# 6144 384 1068.04 +- 0.79 11.80 / 14.07 GiB -# 6144 256 1045.98 +- 1.69 11.97 / 14.33 GiB -# 4096 1536 1161.90 +- 0.96 10.28 / 11.71 GiB -# 2048 1536 1170.23 +- 0.86 10.28 / 11.71 GiB -# 2048 512 1193.88 +- 1.01 11.63 / 13.81 GiB -# -# THE PLATEAU STARTS AT 512. 512 -> 1536 buys ~2 % of prefill -- inside the -# run-to-run spread, and the 512 cells were the faster pair in BOTH batch -# columns -- while costing 1.35 GiB on ROCm0 and 2.10 GiB on ROCm1, so the -# deployment takes the cheap end. BELOW 512 the SMs are no longer saturated: -# 384 and 256 fall ~11 % and ~12 %. The logical batch is free either way -- -# 2048, 4096 and 6144 land within 2 % of each other at a fixed ubatch and use -# the SAME VRAM, because the compute buffers are sized by ubatch alone. -# -# The prefill figure is a property of the SHAPE, not of this knob: the same -# sweep with no depth in front of it (depth 0) runs 1543 tok/s at ubatch 256 and -# 1723 at 1536 -- the plateau is in the same place, and the ~32 % between the two -# tables is what attending over 64k of resident KV costs. Decode is 15.88 tok/s -# at depth 65536 on EVERY ubatch (17.80 at depth 0), so this knob cannot touch -# generation at all. -# -# 8192/2048 stays untested under this drafter (the "2020 MiB pp buffer fails on -# ROCm1" note was measured under DSpark, when it had 0.08 GiB free), and there is -# no longer a reason to want it: batch buys nothing measurable here. -BATCH_SIZE=6144 -UBATCH_SIZE=512 - -# Threads cover tokenization, sampling and HTTP only: every layer is on a GPU. -NUM_THREADS=8 -THREAD_BATCH=8 - -# f16 K/V: an agentic checkpoint is the wrong place to spend KV precision. -CACHE_TYPE_K=f16 -CACHE_TYPE_V=f16 -# Context checkpoints: 0 -- no per-slot KV snapshots at all. The build's -# default is 32 (this binary's --help), so the 0 below is a deliberate -# deviation, not an inherited default: a checkpoint is a VRAM->host-RAM copy -# of part of a slot's KV, taken so a partially-matching prompt can be restored -# instead of re-prefilled. This endpoint trades that away -- its reuse comes -# from the slot prompt and from the RAM-side prompt cache below. Stated -# explicitly so the tunable stays visible rather than merely absent. -CTX_CHECKPOINTS=0 -# PROMPT CACHE, HOST RAM -- this is the one cache tunable that is NOT VRAM. -# --cache-ram caps the total bytes of saved prompt states -# (server_prompt_cache, tools/server/server-task.cpp): update() evicts -# oldest-first past the cap, and alloc() REFUSES outright any single state -# bigger than the whole cap. A state is the serialized KV of a whole sequence -# plus its DeltaNet state plus the draft context's own KV, so at this shape it -# costs ~68 KiB per token -- -# 16 full-attention layers x 4 kv heads x 256 dim x 2 B x 2 (K,V) = 64 KiB -# plus the draft context's window-bounded KV (a ~40 MiB constant for DFlash2) -# -- which makes the cap a TOKEN budget in disguise: 16384 MiB holds ~240k -# tokens, i.e. a FULL 163840-token slot (~10.6 GiB) with ~5 GiB of the budget -# spare for a second entry. -# -# The cost is host RAM, allocated on demand and never pinned (plain heap -# vectors), so no VRAM number above moves. dankserver has 62 GiB beside the -# media-stack co-tenants, so the cache is bounded rather than unlimited (-1): -# the seam test refuses a budget over half the host's RAM. -CACHE_RAM=16384 - -# Sampling defaults for requests that send none -- the values the vLLM -# predecessor served on this endpoint (`--override-generation-config`), so a -# client sees no change across the runtime swap. The model card's own -# recommendation is temp 1.0 / top_p 0.95; the harness sets its own per model in -# models.yml, so this is the floor, not the contract. -TEMPERATURE=0.7 -TOP_P=0.95 -TOP_K=20 -MIN_P=0.0 -PRESENCE_PENALTY=0.0 - -# Reasoning on, projected into reasoning_content (deepseek format) with no -# budget: the model decides how long to think. -REASONING=on -REASONING_FORMAT=deepseek -REASONING_BUDGET=-1 - -# Chat template: empty = the one embedded in the GGUF (Qwen3.8's own). -CHAT_TEMPLATE_FILE= -CHAT_TEMPLATE_KWARGS='{"enable_thinking":true,"preserve_thinking":true}' - -# Speculative decoding: the DFlash2 sidecar (operator call 2026-09-19). The -# target file also carries its own MTP head (nextn_predict_layers=1), so -# draft-mtp is one SPEC_TYPE change away -- the drafter file stays on disk. -# n_max 7 is the measured DFlash2 winner (see the table below); the drafter -# rides ROCm1, the card that holds the last layers. -SPEC_TYPE=draft-dflash -SPEC_DRAFT_N_MAX=7 -SPEC_DRAFT_NGL=999 -SPEC_DRAFT_DEVICE=ROCm1 -SPEC_DRAFT_TYPE_K=f16 -SPEC_DRAFT_TYPE_V=f16 - -# DFlash2 vs the model's own MTP head -- MEASURED 2026-09-16 on the predecessor -# payload (temperature 0, 1k prompt / 512 out; the autoregressive baseline and -# both MTP depths were run in one session, same binary, same prompts): -# -# none 17.7 tok/s card1 26.07 GB -# draft-mtp n=3 32.4 tok/s 58 % accepted card1 29.35 GB -# draft-mtp n=7 26.7 tok/s 33 % accepted card1 29.65 GB -# draft-dflash 7 43.5 tok/s 41 % accepted card1 29.42 GB -# -# All four produced IDENTICAL greedy text, so verification is lossless -# whichever proposer runs. DFlash2 is the faster proposer (2.46x over -# autoregressive, 34 % over the best MTP). The MTP head adds ~3.3 GB to the -# card that holds it, against 1.06 GB for the Q4_K_M drafter's file -- at the -# 327680 pool the drafter's window-bounded KV (~40 MiB) leaves headroom on -# both cards, so the speed wins. -# -# THE TWO CANNOT BE CHAINED. `--spec-type draft-mtp,draft-dflash` dies at load: -# context type MTP requested but model doesn't contain MTP layers -# failed to create MTP context -# A chain has ONE draft context, and MTP builds against whatever model that -# context holds -- here the DFlash2 file, which has no MTP layers. MTP's head is -# only loaded when MTP IS the draft model ("creating MTP draft context against -# the target model ..."), so a chain of the two is a structural dead end, not a -# tuning question. See serverAppConfigs-docs/LLM/frank-medium-llamacpp-dflash2.md. - -# ngram-mod: the draftless n-gram matcher (CPU hash table in host RAM, no VRAM, -# no model file, no draft context). It joins the chain unconditionally -- -# unlike MTP, which needs a draft context and therefore cannot share one with -# DFlash2. NOT CURRENTLY ACTIVE: SPEC_TYPE is draft-dflash alone, and the serve -# script only passes --spec-ngram-mod-* when the type contains "ngram". The -# window below is retained because the DFlash2 + ngram chain was measured free -# on prose and 2.6x on prompt-echo -- one SPEC_TYPE change brings it back. -# -# WINDOW MEASURED 2026-09-16 (chain at each window, decode tok/s): -# -# n_min/n_max prose 1k/512 prompt-echo 1.5k/400 -# 48/64 43.9 200.6 -# 16/32 36.0 161.0 -# 8/16 36.2 113.8 -# -# The long window is the right one, and not only because it wins on echo: a -# SHORT window makes the matcher propose on short fragments of ordinary prose, -# where the proposals are rejected and the verification still costs -- which is -# the "ngram hurts" result the fleet measured on other payloads twice before. -# With 48/64 the chain is byte-identical to DFlash2 alone on prose (same 119 -# drafts / 49 accepted) and on the identity prompt (70/53). -# -# Do not shorten these without re-running scripts/bench-spec-ab.py: the -# no-penalty property is what makes the chain safe as a default. -SPEC_NGRAM_N_MATCH=24 -SPEC_NGRAM_N_MIN=48 -SPEC_NGRAM_N_MAX=64 - -# Vision enabled: llama-server loads mmproj-Qwen3.8-27B-Uncensored-f16.gguf -# (931 MiB, lands on ROCm0 -- the fit charges it there) via the explicit -# --mmproj flag. This keeps frank-medium multimodal across the model swap. -NO_MMPROJ=0 diff --git a/scripts/.AGENTTRASH/20260920-llama-layersplit/frank-medium.service.llamacpp-layersplit b/scripts/.AGENTTRASH/20260920-llama-layersplit/frank-medium.service.llamacpp-layersplit deleted file mode 100644 index 5523942..0000000 --- a/scripts/.AGENTTRASH/20260920-llama-layersplit/frank-medium.service.llamacpp-layersplit +++ /dev/null @@ -1,20 +0,0 @@ -[Unit] -Description=llama.cpp Qwen3.8-27B Uncensored UD-Q8_K_XL + DFlash2 drafter (dankserver, frank-medium, 2x Radeon AI PRO R9700) -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -User=dank -Group=dank -# The engine pins staging buffers on the first card; keep the memlock budget -# unbounded so an RLIMIT that is smaller than the split buffers cannot fail the -# load. Parity with the other LLM units on this fleet. -LimitMEMLOCK=infinity -Environment="FRANK_MEDIUM_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-medium.env" -ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-medium.sh -Restart=on-failure -RestartSec=10 - -[Install] -WantedBy=multi-user.target diff --git a/scripts/.AGENTTRASH/20260920-llama-layersplit/serve-frank-medium.sh.llamacpp-layersplit b/scripts/.AGENTTRASH/20260920-llama-layersplit/serve-frank-medium.sh.llamacpp-layersplit deleted file mode 100755 index 339f600..0000000 --- a/scripts/.AGENTTRASH/20260920-llama-layersplit/serve-frank-medium.sh.llamacpp-layersplit +++ /dev/null @@ -1,203 +0,0 @@ -#!/usr/bin/env bash -# Serve ajgazin/Swift-Qwen3.8-27B-Uncensored-Dynamic-MTP-GGUF @ UD-Q8_K_XL as -# frank-medium on Dankserver's two Radeon AI PRO R9700 (gfx1201), with the -# z-lab DFlash2 block-diffusion drafter, using mainline llama.cpp. -# -# Runtime: llama.cpp master fb27a525 (2026-09-16), a gfx1201 HIP build that -# bundles its own ROCm 7.14 runtime (RPATH $ORIGIN/../lib), produced by -# scripts/build-llama-rdna4.sh. Two features pin that ref: DFlash2 -# (ggml-org/llama.cpp#27342) and qwen35 MTP. The charlie12345/ROCmFPX fork -# this deployment replaced has neither (its tip is c49ebdb, 2026-08-22). -# -# Parallelism: --split-mode layer across both cards. Tensor split would need a -# cross-card allreduce and this host cannot dispatch one -- RCCL's kernels -# require a hostcall buffer that ROCr refuses on a card without PCIe atomics, -# and the second R9700 sits behind the chipset (AtomicOpsCap: Routing-). Layer -# split crosses the wire once per forward pass instead of once per layer. -# -# The vLLM replica pair and its routing proxy were retired with this cutover: -# one process, one model, both cards. -# -# Tunables come from frank-medium.env unless FRANK_MEDIUM_ENV_FILE overrides it. -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -ENV_FILE="${FRANK_MEDIUM_ENV_FILE:-$SCRIPT_DIR/frank-medium.env}" -if [[ -f "$ENV_FILE" ]]; then - set -a - # shellcheck disable=SC1090 - source "$ENV_FILE" - set +a -fi - -# --- defaults (used when the env file does not set them) ------------------ -: "${LLAMA_BIN:=/home/dank/GitPackages/llama.cpp-dflash2/build-rdna4-rocm714/bin/llama-server}" -: "${MODEL_DIR:=/home/dank/models/Swift-Qwen3.8-27B-Uncensored-Dynamic-MTP-GGUF}" -: "${MODEL_FILE:=Swift-Qwen3.8-27B-Uncensored-Dynamic-MTP-UD-Q8_K_XL.gguf}" -: "${DRAFT_MODEL_DIR:=/home/dank/models/Qwen3.8-27B-DFlash2-GGUF}" -: "${DRAFT_MODEL_FILE:=Qwen3.8-27B-DFlash2-Q8_0.gguf}" -: "${ALIAS:=frank-medium}" -: "${BIND_ADDR:=0.0.0.0}" -: "${LISTEN_PORT:=7770}" - -# Devices. HIP_VISIBLE_DEVICES hides the Raphael iGPU (HIP device 2) so nothing -# can drift onto shared system RAM; DEVICES is what llama.cpp splits across. -: "${HIP_VISIBLE_DEVICES:=0,1}" -: "${DEVICES:=ROCm0,ROCm1}" -: "${SPLIT_MODE:=layer}" -: "${TENSOR_SPLIT:=1,1}" -: "${NGL:=999}" -: "${FIT:=off}" -: "${FLASH_ATTN:=on}" - -: "${CTX_SIZE:=131072}" -: "${PARALLEL:=1}" -: "${BATCH_SIZE:=2048}" -: "${UBATCH_SIZE:=512}" -: "${NUM_THREADS:=8}" -: "${THREAD_BATCH:=8}" -: "${CACHE_TYPE_K:=f16}" -: "${CACHE_TYPE_V:=f16}" -: "${CTX_CHECKPOINTS:=8}" -: "${CACHE_RAM:=8192}" - -# Sampling defaults for silent clients: the values the vLLM predecessor served. -: "${TEMPERATURE:=0.7}" -: "${TOP_P:=0.95}" -: "${TOP_K:=20}" -: "${MIN_P:=0.0}" -: "${PRESENCE_PENALTY:=0.0}" - -: "${REASONING:=on}" -: "${REASONING_FORMAT:=deepseek}" -: "${REASONING_BUDGET:=-1}" -: "${CHAT_TEMPLATE_FILE:=}" -: "${CHAT_TEMPLATE_KWARGS:={\"enable_thinking\":true,\"preserve_thinking\":true}}" - -# Speculative decoding: draft-dflash or draft-dspark (both are MODEL drafters -- -# a file, not a flag), or draft-mtp (the target GGUF's own MTP head, no draft -# file). The drafter's own tuning knobs (n-min, p-min, p-split, poll) are left -# at the build's defaults: they were only ever passed for dspark, and dspark is -# not the deployed drafter. -: "${SPEC_TYPE:=draft-dflash}" -: "${SPEC_DRAFT_N_MAX:=7}" -: "${SPEC_DRAFT_NGL:=999}" -: "${SPEC_DRAFT_DEVICE:=}" -: "${SPEC_DRAFT_TYPE_K:=f16}" -: "${SPEC_DRAFT_TYPE_V:=f16}" -: "${SPEC_NGRAM_N_MATCH:=24}" -: "${SPEC_NGRAM_N_MIN:=48}" -: "${SPEC_NGRAM_N_MAX:=64}" - -NO_MMPROJ="${NO_MMPROJ:-0}" -: "${MM_PROJ_FILE:=mmproj-BF16.gguf}" - -export HIP_VISIBLE_DEVICES - -MODEL="$MODEL_DIR/$MODEL_FILE" -DRAFT_MODEL="$DRAFT_MODEL_DIR/$DRAFT_MODEL_FILE" - -if [[ ! -x "$LLAMA_BIN" ]]; then - echo "missing executable runtime: $LLAMA_BIN" >&2 - echo "build it with: bash $SCRIPT_DIR/build-llama-rdna4.sh" >&2 - exit 1 -fi -if [[ ! -r "$MODEL" ]]; then - echo "missing or unreadable target model: $MODEL" >&2 - echo "fetch it with: bash $SCRIPT_DIR/download-frank-medium-gguf.sh" >&2 - exit 1 -fi -if [[ "$NO_MMPROJ" != "1" ]]; then - MM_PROJ="$MODEL_DIR/$MM_PROJ_FILE" - if [[ ! -r "$MM_PROJ" ]]; then - echo "vision is enabled but the projector is missing: $MM_PROJ" >&2 - echo "fetch it with: bash $SCRIPT_DIR/download-frank-medium-gguf.sh" >&2 - exit 1 - fi -fi -if [[ -n "$CHAT_TEMPLATE_FILE" && ! -r "$CHAT_TEMPLATE_FILE" ]]; then - echo "missing or unreadable chat template: $CHAT_TEMPLATE_FILE" >&2 - exit 1 -fi - -# --- speculative decoding ------------------------------------------------- -SPEC_ARGS=() -if [[ -n "$SPEC_TYPE" && "$SPEC_TYPE" != "none" ]]; then - SPEC_ARGS=(--spec-type "$SPEC_TYPE" --spec-draft-n-max "$SPEC_DRAFT_N_MAX") - if [[ "$SPEC_TYPE" == *dflash* || "$SPEC_TYPE" == *dspark* ]]; then - if [[ ! -r "$DRAFT_MODEL" ]]; then - echo "missing or unreadable draft model: $DRAFT_MODEL" >&2 - exit 1 - fi - SPEC_ARGS+=( - --spec-draft-model "$DRAFT_MODEL" - --spec-draft-ngl "$SPEC_DRAFT_NGL" - --spec-draft-type-k "$SPEC_DRAFT_TYPE_K" - --spec-draft-type-v "$SPEC_DRAFT_TYPE_V" - ) - fi - # ngram-mod is draftless: it needs no draft context and its window is its - # own (--spec-ngram-mod-*), so it can ride in the chain beside a model - # drafter. It also costs no VRAM -- a CPU hash table. - if [[ "$SPEC_TYPE" == *ngram* ]]; then - SPEC_ARGS+=( - --spec-ngram-mod-n-match "$SPEC_NGRAM_N_MATCH" - --spec-ngram-mod-n-min "$SPEC_NGRAM_N_MIN" - --spec-ngram-mod-n-max "$SPEC_NGRAM_N_MAX" - ) - fi - if [[ -n "$SPEC_DRAFT_DEVICE" ]]; then - SPEC_ARGS+=(--spec-draft-device "$SPEC_DRAFT_DEVICE") - fi -fi - -# --- template + placement ------------------------------------------------- -TEMPLATE_ARGS=() -if [[ -n "$CHAT_TEMPLATE_FILE" ]]; then - TEMPLATE_ARGS+=(--chat-template-file "$CHAT_TEMPLATE_FILE") -fi -TEMPLATE_ARGS+=(--chat-template-kwargs "$CHAT_TEMPLATE_KWARGS") - -PLACEMENT_ARGS=(--device "$DEVICES" --split-mode "$SPLIT_MODE" --tensor-split "$TENSOR_SPLIT") - -MMPROJ_ARGS=() -if [[ "$NO_MMPROJ" == "1" ]]; then - MMPROJ_ARGS=(--no-mmproj) -else - # No autoload in this build: a server without the flag starts text-only - # and answers image requests with a 500, so name the projector. - MMPROJ_ARGS=(--mmproj "$MM_PROJ") -fi -exec "$LLAMA_BIN" \ - --model "$MODEL" \ - --alias "$ALIAS" \ - --host "$BIND_ADDR" \ - --port "$LISTEN_PORT" \ - --jinja \ - --gpu-layers "$NGL" \ - --fit "$FIT" \ - --parallel "$PARALLEL" \ - --ctx-size "$CTX_SIZE" \ - --batch-size "$BATCH_SIZE" \ - --ubatch-size "$UBATCH_SIZE" \ - --flash-attn "$FLASH_ATTN" \ - --cache-type-k "$CACHE_TYPE_K" \ - --cache-type-v "$CACHE_TYPE_V" \ - --threads "$NUM_THREADS" \ - --threads-batch "$THREAD_BATCH" \ - --ctx-checkpoints "$CTX_CHECKPOINTS" \ - --cache-ram "$CACHE_RAM" \ - --cache-prompt \ - --metrics \ - --temp "$TEMPERATURE" \ - --top-p "$TOP_P" \ - --top-k "$TOP_K" \ - --min-p "$MIN_P" \ - --presence-penalty "$PRESENCE_PENALTY" \ - --reasoning "$REASONING" \ - --reasoning-format "$REASONING_FORMAT" \ - --reasoning-budget "$REASONING_BUDGET" \ - "${TEMPLATE_ARGS[@]}" \ - "${PLACEMENT_ARGS[@]}" \ - "${MMPROJ_ARGS[@]}" \ - "${SPEC_ARGS[@]}" diff --git a/scripts/.AGENTTRASH/bench-decode-threads-batch.py.strix-rocmfp4 b/scripts/.AGENTTRASH/bench-decode-threads-batch.py.strix-rocmfp4 deleted file mode 100755 index 0087886..0000000 --- a/scripts/.AGENTTRASH/bench-decode-threads-batch.py.strix-rocmfp4 +++ /dev/null @@ -1,192 +0,0 @@ -#!/usr/bin/env python3 -""" -Decode throughput benchmark for frank-dragoon. -Starts the server with a given --threads-batch value, sends an 8K+ token prompt, -generates 256 tokens, measures generation tok/s. - -Each --threads-batch value requires a cold server load (~3 min). -""" -import json, time, urllib.request, urllib.error, subprocess, os, sys - -MODEL = "/home/dank/models/kingjones-Qwen3.8-Flash-Next-ROCmFP4-STRIX/Qwen3.8-Flash-Next-Q4_0-ROCmFP4-STRIX.gguf" -SERVE = "/home/dank/GitPackages/ROCmFPX/build-qwen38/bin/llama-server" -PORT = 7770 -N_GEN = 256 -PROMPT_FILE = "/tmp/bench-prompts/prompt_code.txt" -ENV_FILE = os.path.join(os.path.dirname(__file__), "frank-dragoon.env") -SERVER_DIR = os.path.dirname(SERVE) -ROCM_PATH = "/opt/rocm" - -def make_env(): - """Build the same env as serve-frank-dragoon.sh.""" - env = {**os.environ, "HSA_OVERRIDE_GFX_VERSION": "11.5.1", "HIP_VISIBLE_DEVICES": "0"} - - # Source env file for tunables - if os.path.exists(ENV_FILE): - with open(ENV_FILE) as f: - for line in f: - line = line.strip() - if line and not line.startswith("#") and "=" in line: - k, v = line.split("=", 1) - env[k] = v - - # Build LD_LIBRARY_PATH - lib_path = f"{SERVER_DIR}:{ROCM_PATH}/lib:{ROCM_PATH}/lib/rocm_sysdeps/lib:{ROCM_PATH}/lib/llvm/lib" - if os.environ.get("LD_LIBRARY_PATH"): - lib_path += f":{os.environ['LD_LIBRARY_PATH']}" - env["LD_LIBRARY_PATH"] = lib_path - env["ROCM_PATH"] = ROCM_PATH - - return env - -ENV = make_env() - -def kill_all(): - subprocess.run(["pkill", "-9", "llama-server"], capture_output=True) - time.sleep(3) - -def wait_ready(): - for i in range(600): - try: - r = urllib.request.urlopen(f"http://127.0.0.1:{PORT}/health", timeout=2) - if json.loads(r.read()).get("status") == "ok": - return True - except: - pass - time.sleep(1) - return False - -def send_request(prompt, max_tokens=N_GEN): - """Send chat completion, measure generation throughput.""" - body = json.dumps({ - "model": "frank-dragoon", - "messages": [{"role": "user", "content": prompt}], - "max_tokens": max_tokens, - "stream": False, - "temperature": 0.7, - "top_p": 0.8 - }).encode() - - t0 = time.time() - req = urllib.request.Request( - f"http://127.0.0.1:{PORT}/v1/chat/completions", - data=body, - headers={"Content-Type": "application/json"}, - method="POST" - ) - r = urllib.request.urlopen(req, timeout=1200) - resp = json.loads(r.read()) - wall = time.time() - t0 - - usage = resp.get("usage", {}) - pt = usage.get("prompt_tokens", 0) - gt = usage.get("completion_tokens", 0) - gtps = gt / wall if wall > 0 else 0 - return pt, gt, wall, gtps - -def load_and_extend_prompt(): - """Load prompt and extend to ~8K tokens if needed.""" - with open(PROMPT_FILE) as f: - text = f.read() - - # Count approximate tokens (rough: ~4 chars per token for code) - approx_tokens = len(text) / 4.0 - needed = 8200 - - if approx_tokens < needed: - repeats = needed // max(int(approx_tokens), 1) + 1 - text = (text + "\n") * repeats - words = text.split()[:needed] - text = " ".join(words) - - return text - -def main(): - tb_values = [int(x) for x in sys.argv[1:]] if len(sys.argv) > 1 else [16, 32, 64, 128] - results = [] - - prompt = load_and_extend_prompt() - n_words = len(prompt.split()) - print(f"Prompt: {n_words} words (~{n_words} tokens), {len(prompt)} chars\n", flush=True) - - for tb in tb_values: - print(f"\n{'='*60}") - print(f"threads-batch = {tb}") - print(f"{'='*60}", flush=True) - - kill_all() - print(" Starting server...", flush=True) - - lib_path = ENV.get("LD_LIBRARY_PATH", "") - cmd = [ - "/usr/bin/env", - "HSA_OVERRIDE_GFX_VERSION=11.5.1", - "HIP_VISIBLE_DEVICES=0", - f"LD_LIBRARY_PATH={lib_path}", - f"ROCM_PATH={ROCM_PATH}", - SERVE, "--model", MODEL, "--port", str(PORT), "--jinja", - "--device", "ROCm0", "--gpu-layers", "999", "--fit", "off", - "--threads", "16", "--threads-batch", str(tb), - "--parallel", "2", "--ctx-size", "131072", - "--batch-size", "4096", "--ubatch-size", "4096", - "--flash-attn", "on", - "--cache-type-k", "f16", "--cache-type-v", "f16", - "--cache-prompt", "--cache-idle-slots", "--metrics" - ] - - srv = subprocess.Popen(cmd, env=ENV, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) - - ready = wait_ready() - if not ready: - print(" FAILED: server did not become ready", flush=True) - srv.terminate() - srv.wait() - for line in (srv.stdout.readlines() or [])[-20:]: - print(f" LOG: {line.rstrip()}", flush=True) - continue - - print(" Server ready.", flush=True) - - # Warmup (small request) - try: - send_request("Hi", 16) - except Exception as e: - print(f" Warmup failed: {e}", flush=True) - srv.terminate() - srv.wait() - continue - - # Benchmark - print(f" Generating {N_GEN} tokens...", flush=True) - try: - pt, gt, wall, gtps = send_request(prompt, N_GEN) - print(f" {gt}/{N_GEN} gen tokens in {wall:.1f}s = {gtps:.1f} tok/s") - print(f" Prompt tokens: {pt}") - results.append({"tb": tb, "wall": wall, "gtps": gtps, "gt": gt, "pt": pt}) - except Exception as e: - print(f" FAILED: {e}", flush=True) - - srv.terminate() - srv.wait() - time.sleep(2) - - # Summary - print(f"\n{'='*60}") - print("SUMMARY") - print(f"{'='*60}") - for r in results: - print(f" tb={r['tb']:>3}: {r['gt']} gen tokens, {r['wall']:.1f}s, {r['gtps']:.1f} tok/s") - - if results: - best = max(results, key=lambda r: r['gtps']) - print(f"\nBest: tb={best['tb']} at {best['gtps']:.1f} tok/s") - - with open("/tmp/bench_decode_results.txt", "w") as f: - f.write("tb,gen_tokens,wall_s,gen_tps,prompt_tokens\n") - for r in results: - f.write(f"{r['tb']},{r['gt']},{r['wall']:.1f},{r['gtps']:.1f},{r['pt']}\n") - - print(f"\nResults: /tmp/bench_decode_results.txt") - -if __name__ == "__main__": - main() diff --git a/scripts/.AGENTTRASH/bench-ngram-mod.py.ik-llama.cpp b/scripts/.AGENTTRASH/bench-ngram-mod.py.ik-llama.cpp deleted file mode 100755 index d60f1e5..0000000 --- a/scripts/.AGENTTRASH/bench-ngram-mod.py.ik-llama.cpp +++ /dev/null @@ -1,268 +0,0 @@ -#!/usr/bin/env python3 -""" -Benchmark n-gram speculative decoding vs no-spec for frank-dragoon. - -Tests prompt-processing throughput with 16K token prompts (manageable request size). -Code-heavy prompt (high n-gram redundancy) vs prose prompt (low redundancy). - -Usage: - # Generate prompts: python3 scripts/bench-ngram-mod.py generate /tmp/prompts/ - # Run: python3 scripts/bench-ngram-mod.py /tmp/prompts/ -""" - -import json -import os -import sys -import time -import subprocess -import urllib.request -import urllib.error -from pathlib import Path - -MODEL = "/home/dank/models/kingjones-Qwen3.8-Flash-Next-ROCmFP4-STRIX/Qwen3.8-Flash-Next-Q4_0-ROCmFP4-STRIX.gguf" -CHAT_TEMPLATE = "/home/dank/serverAppConfigs/scripts/chat-template.jinja" -BUILD_DIR = "/home/dank/GitPackages/ROCmFPX/build-qwen38/bin" -LLAMA_BIN = os.path.join(BUILD_DIR, "llama-server") - -# 16K tokens ~= 64KB text -TARGET_PROMPT_TOKENS = 16000 -TOKEN_CHAR_RATIO = 4 -TARGET_PROMPT_BYTES = TARGET_PROMPT_TOKENS * TOKEN_CHAR_RATIO - -CONTEXT = 65536 -BATCH = 4096 -UBATCH = 4096 -THREADS = 16 -THREAD_BATCH = 32 -NGRAM_N_MAX = 24 -NGRAM_N_MIN = 4 -TEST_TOKENS = 128 -SERVER_WAIT = 300 - - -def start_server(port, spec_type): - cmd = [ - LLAMA_BIN, - "--model", MODEL, - "--chat-template-file", CHAT_TEMPLATE, - "--jinja", - "--device", "ROCm0", - "--gpu-layers", "999", - "--parallel", "1", - "--ctx-size", str(CONTEXT), - "--batch-size", str(BATCH), - "--ubatch-size", str(UBATCH), - "--fit", "off", - "--flash-attn", "on", - "--cache-type-k", "f16", - "--cache-type-v", "f16", - "--threads", str(THREADS), - "--threads-batch", str(THREAD_BATCH), - "--cache-prompt", - "--cache-idle-slots", - "--metrics", - "--host", "127.0.0.1", - "--port", str(port), - "--temp", "0", - ] - if spec_type == "ngram-mod": - cmd.extend([ - "--spec-type", "ngram-mod", - "--spec-ngram-mod-n-max", str(NGRAM_N_MAX), - "--spec-ngram-mod-n-min", str(NGRAM_N_MIN), - ]) - else: - cmd.extend(["--spec-type", "none"]) - - env = os.environ.copy() - env["ROCM_PATH"] = "/opt/rocm" - env["LD_LIBRARY_PATH"] = f"{BUILD_DIR}:/opt/rocm/lib:/opt/rocm/lib/rocm_sysdeps/lib:/opt/rocm/lib/llvm/lib" - env["HSA_OVERRIDE_GFX_VERSION"] = "11.5.1" - env["HIP_VISIBLE_DEVICES"] = "0" - - proc = subprocess.Popen(cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - - print(f" Waiting for server on port {port} (spec={spec_type})...") - for i in range(SERVER_WAIT): - try: - req = urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=2) - resp = json.loads(req.read().decode()) - if resp.get("status") == "ok": - print(f" Server ready after {i+1}s") - return proc - except: - time.sleep(1) - - proc.terminate() - out, err = proc.communicate() - print(f"ERROR: Server failed to start in {SERVER_WAIT}s") - print(err.decode()[-2000:]) - sys.exit(1) - - -def run_test(prompt_text, spec_type, port): - """Send a request and extract prompt processing metrics.""" - data = json.dumps({ - "model": "frank-dragoon", - "messages": [{"role": "user", "content": prompt_text}], - "max_tokens": TEST_TOKENS, - "stream": False, - "temperature": 0, - }).encode() - - req = urllib.request.Request( - f"http://127.0.0.1:{port}/v1/chat/completions", - data=data, - headers={"Content-Type": "application/json"}, - ) - - start = time.monotonic() - resp = urllib.request.urlopen(req, timeout=600) - elapsed = time.monotonic() - start - - result = json.loads(resp.read().decode()) - timings = result.get("timings", {}) - usage = result.get("usage", {}) - - return { - "prompt_tokens": usage.get("prompt_tokens", 0), - "prompt_ms": timings.get("prompt_ms", 0), - "prompt_per_second": timings.get("prompt_per_second", 0), - "prompt_per_token_ms": timings.get("prompt_per_token_ms", 0), - "predicted_per_second": timings.get("predicted_per_second", 0), - "total_time_s": elapsed, - "cache_n": timings.get("cache_n", 0), - } - - -def main(): - if len(sys.argv) < 2: - print(f"Usage: {sys.argv[0]} <prompt_dir>") - sys.exit(1) - - prompt_dir = Path(sys.argv[1]) - prompt_dir.mkdir(parents=True, exist_ok=True) - - code_prompt_file = prompt_dir / "prompt_code.txt" - prose_prompt_file = prompt_dir / "prompt_prose.txt" - - # Generate prompts if they don't exist - if not code_prompt_file.exists() or not prose_prompt_file.exists(): - print("Generating prompts...") - # Generate deterministic prompts - # Code prompt: structured algorithms + data generation + config schemas - code_lines = [] - for i in range(50): - code_lines.append(f"# Algorithm {i}\n") - code_lines.append(f"def algo_{i}(x, y):\n") - code_lines.append(f" \"\"\"Algorithm {i} processing.\"\"\"\n") - code_lines.append(f" result = 0\n") - code_lines.append(f" for j in range({i * 10}):\n") - code_lines.append(f" result += x * y + j\n") - code_lines.append(f" return result\n\n") - code_text = "".join(code_lines) - # Pad/truncate to target - while len(code_text) < TARGET_PROMPT_BYTES: - code_text += "# Additional algorithm block\n" - code_text += f"def extra_{len(code_text)//100}():\n" - code_text += " x = 1\n" - code_text += " y = 2\n" - code_text += " return x + y\n\n" - code_text = code_text[:TARGET_PROMPT_BYTES] - - # Prose prompt: historical/scientific/philosophical text - prose_text = "" - topics = [ - "The Renaissance brought profound transformation to European civilization. Artists and scholars pushed the boundaries of human expression.", - "Quantum mechanics describes the behavior of matter and energy at atomic scales. The uncertainty principle limits measurement precision.", - "Ethical philosophy grapples with questions of right and wrong. Utilitarianism judges actions by their consequences and the happiness they produce.", - "Mountains rise like ancient sentinels, their peaks lost in clouds that swirl and dance across the sky above the valleys below.", - "The ocean whispers secrets to the shore, each wave carrying fragments of distant lands across the water before retreating back to sea.", - ] - for _ in range(500): - prose_text += f"{topics[len(prose_text) % len(topics)]} " - prose_text = prose_text[:TARGET_PROMPT_BYTES] - - code_prompt_file.write_text(code_text) - prose_prompt_file.write_text(prose_text) - print(f" Code: {code_prompt_file.stat().st_size:,} bytes") - print(f" Prose: {prose_prompt_file.stat().st_size:,} bytes") - - print("=" * 60) - print(" N-gram Mod Benchmark — frank-dragoon") - print(f" STRIX ROCmFP4 · 64K context · 16K prompts") - print("=" * 60) - - results = [] - port_base = 7772 - port_ngram = 7773 - - tests = [ - ("code", "none", port_base, code_prompt_file), - ("prose", "none", port_ngram, prose_prompt_file), - ("code", "ngram-mod", port_base, code_prompt_file), - ("prose", "ngram-mod", port_ngram, prose_prompt_file), - ] - - for prompt_type, spec_type, port, pfile in tests: - print(f"\n=== {spec_type} ({prompt_type}) ===") - print(f" File: {pfile} ({pfile.stat().st_size:,} bytes)") - print(f" Spec: {spec_type}") - if spec_type == "ngram-mod": - print(f" n_max={NGRAM_N_MAX}, n_min={NGRAM_N_MIN}") - - proc = start_server(port, spec_type) - - try: - # Warm-up - print(" Warm-up...") - run_test("Hello test", spec_type, port) - - # Full prompt - print(f" Sending {TARGET_PROMPT_TOKENS} token prompt...") - with open(pfile) as f: - prompt_text = f.read() - result = run_test(prompt_text, spec_type, port) - - tps = result["prompt_per_second"] - print(f" Tokens: {result['prompt_tokens']}") - print(f" Prompt: {result['prompt_ms']:.0f}ms") - print(f" TPS: {tps:.1f} tok/s") - print(f" Total: {result['total_time_s']:.1f}s") - - results.append({ - "prompt_type": prompt_type, - "spec_type": spec_type, - "prompt_tokens": result["prompt_tokens"], - "prompt_ms": result["prompt_ms"], - "tps": tps, - "total_time_s": result["total_time_s"], - }) - - finally: - proc.terminate() - try: - proc.wait(timeout=5) - except: - proc.kill() - - # Summary - print("\n" + "=" * 60) - print(" RESULTS SUMMARY") - print("=" * 60) - - for ptype in ["code", "prose"]: - baseline = next((r for r in results if r["prompt_type"] == ptype and r["spec_type"] == "none"), None) - ngram = next((r for r in results if r["prompt_type"] == ptype and r["spec_type"] == "ngram-mod"), None) - if baseline and ngram: - gain = ((ngram["tps"] - baseline["tps"]) / baseline["tps"]) * 100 if baseline["tps"] > 0 else 0 - print(f"\n{ptype.upper()} PROMPT:") - print(f" Baseline TPS: {baseline['tps']:.1f} tok/s ({baseline['prompt_ms']:.0f}ms)") - print(f" N-gram TPS: {ngram['tps']:.1f} tok/s ({ngram['prompt_ms']:.0f}ms)") - print(f" Speedup: {gain:+.1f}%") - - print("\n" + "=" * 60) - - -if __name__ == "__main__": - main() diff --git a/scripts/.AGENTTRASH/bench-ngram-mod.sh.ik-llama.cpp b/scripts/.AGENTTRASH/bench-ngram-mod.sh.ik-llama.cpp deleted file mode 100755 index dc5a512..0000000 --- a/scripts/.AGENTTRASH/bench-ngram-mod.sh.ik-llama.cpp +++ /dev/null @@ -1,304 +0,0 @@ -#!/usr/bin/env bash -# Benchmark n-gram speculative decoding vs no-spec for frank-dragoon. -# -# This tests prompt-processing throughput at 64k context with two distinct -# prompts: one code-heavy and one prose-heavy. Uses llama-server on scratch -# ports with the OpenAI-compatible API to measure prompt_eval_duration_ms. -# -# Run on Dankclaw after stopping the production service: -# sudo systemctl stop frank-dragoon -# ./scripts/bench-ngram-mod.sh -# sudo systemctl start frank-dragoon - -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -ENV_FILE="${FRANK_DRAGOON_ENV_FILE:-$SCRIPT_DIR/frank-dragoon.env}" -if [[ -f "$ENV_FILE" ]]; then - set -a; source "$ENV_FILE"; set +a -fi - -: "${LLAMA_BIN:=/home/dank/GitPackages/ROCmFPX/build-qwen38/bin/llama-server}" -: "${MODEL_DIR:=/home/dank/models/kingjones-Qwen3.8-Flash-Next-ROCmFP4-STRIX}" -: "${MODEL_FILE:=Qwen3.8-Flash-Next-Q4_0-ROCmFP4-STRIX.gguf}" -: "${CHAT_TEMPLATE_FILE:=$SCRIPT_DIR/chat-template.jinja}" -: "${CHAT_TEMPLATE_KWARGS:={}}" -: "${BATCH_SIZE:=4096}" -: "${UBATCH_SIZE:=4096}" -: "${CTX_SIZE:=65536}" -: "${NUM_THREADS:=16}" -: "${THREAD_BATCH:=32}" -: "${CACHE_TYPE_K:=f16}" -: "${CACHE_TYPE_V:=f16}" -: "${FIT:=off}" -: "${TEMPERATURE:=0.7}" -: "${TOP_P:=0.8}" -: "${TOP_K:=20}" -: "${MIN_P:=0.0}" -: "${REASONING:=on}" -: "${REASONING_FORMAT:=deepseek}" - -MODEL="$MODEL_DIR/$MODEL_FILE" -HOSTNAME=$(hostname 2>/dev/null || echo "dankclaw") - -# Prompt files (generated by generate-prompts.py or passed via args) -PROMPT_CODE="${1:-}" -PROMPT_PROSE="${2:-}" - -if [[ -z "$PROMPT_CODE" || -z "$PROMPT_PROSE" ]]; then - echo "ERROR: Need two prompt file paths." - echo "Usage: $0 <prompt_code.txt> <prompt_prose.txt>" - echo "Generate with: python3 scripts/generate-prompts.py" - exit 1 -fi - -if [[ ! -r "$MODEL" ]]; then - echo "missing or unreadable model: $MODEL" >&2 - exit 1 -fi - -SERVER_DIR="$(cd -- "$(dirname -- "$LLAMA_BIN")" && pwd)" -ROCM_PATH="${ROCM_PATH:-/opt/rocm}" -LIBRARY_PATH="$SERVER_DIR:$ROCM_PATH/lib:$ROCM_PATH/lib/rocm_sysdeps/lib:$ROCM_PATH/lib/llvm/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" - -# Scratch ports -PORT_NGRAM=7771 -PORT_BASELINE=7772 - -cleanup() { - echo "Cleaning up servers..." - # Kill any leftover llama-server on our ports - for port in $PORT_NGRAM $PORT_BASELINE; do - fuser -k "$port"/tcp 2>/dev/null || true - done -} -trap cleanup EXIT - -wait_for_server() { - local port=$1 - local max_wait=120 - local elapsed=0 - while (( elapsed < max_wait )); do - if curl -s "http://127.0.0.1:$port/health" | grep -q '"running"'; then - return 0 - fi - sleep 1 - (( elapsed++ )) - done - echo "ERROR: Server on port $port did not start within ${max_wait}s" >&2 - return 1 -} - -get_prompt_tokens() { - local file=$1 - # Count tokens approximately (1 token ~ 4 chars for English/code) - wc -c < "$file" | awk '{printf "%d", $1/4}' -} - -run_single_test() { - local port=$1 - local spec_type="$2" - local prompt_file=$3 - local label=$4 - local ngram_n_max="${5:-24}" - local ngram_n_min="${6:-4}" - - echo "=== $label ===" - echo " spec-type: $spec_type" - echo " ngram-mod n-max: $ngram_n_max (if enabled)" - echo " ngram-mod n-min: $ngram_n_min (if enabled)" - echo " prompt: $prompt_file ($(wc -c < "$prompt_file") bytes)" - - # Build command - local cmd=( - /usr/bin/env - ROCM_PATH="$ROCM_PATH" - HIP_VISIBLE_DEVICES=0 - LD_LIBRARY_PATH="$LIBRARY_PATH" - HSA_OVERRIDE_GFX_VERSION="${HSA_OVERRIDE_GFX_VERSION:-11.5.1}" - "$LLAMA_BIN" - --model "$MODEL" - --chat-template-file "$CHAT_TEMPLATE_FILE" - --chat-template-kwargs "$CHAT_TEMPLATE_KWARGS" - --host 127.0.0.1 - --port "$port" - --jinja - --device ROCm0 - --gpu-layers 999 - --parallel 1 - --ctx-size "$CTX_SIZE" - --batch-size "$BATCH_SIZE" - --ubatch-size "$UBATCH_SIZE" - --fit "$FIT" - --flash-attn on - --cache-type-k "$CACHE_TYPE_K" - --cache-type-v "$CACHE_TYPE_V" - --threads "$NUM_THREADS" - --threads-batch "$THREAD_BATCH" - --cache-prompt - --cache-idle-slots - --metrics - --temp "$TEMPERATURE" - --top-p "$TOP_P" - --top-k "$TOP_K" - --min-p "$MIN_P" - --reasoning "$REASONING" - --reasoning-format "$REASONING_FORMAT" - --reasoning-budget -1 - ) - - # Add spec flags - if [[ "$spec_type" == "ngram-mod" ]]; then - cmd+=( - --spec-type "ngram-mod" - --spec-ngram-mod-n-max "$ngram_n_max" - --spec-ngram-mod-n-min "$ngram_n_min" - ) - else - cmd+=( - --spec-type "none" - ) - fi - - # Start server with stderr to log - local log_file="/tmp/ngram-bench-${label//[^a-zA-Z0-9]/-}.log" - echo " Starting server (log: $log_file)..." - - "${cmd[@]}" 2> "$log_file" & - local server_pid=$! - - if ! wait_for_server "$port"; then - echo "ERROR: Server failed to start" >&2 - kill "$server_pid" 2>/dev/null || true - return 1 - fi - - echo " Server ready." - - # Send prompt via API - echo " Sending prompt..." - local prompt_text - prompt_text=$(cat "$prompt_file") - - local response - response=$(curl -s -X POST "http://127.0.0.1:$port/v1/completions" \ - -H "Content-Type: application/json" \ - -d "{ - \"model\": \"frank-dragoon\", - \"prompt\": \"$prompt_text\", - \"max_tokens\": 128, - \"temperature\": 0, - \"stream\": false - }") - - # Extract metrics - local prompt_tokens prompt_eval_duration_ms total_duration_ms - prompt_tokens=$(echo "$response" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['prompt_eval_count'])" 2>/dev/null || echo "0") - prompt_eval_duration_ms=$(echo "$response" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['prompt_eval_duration_ms'])" 2>/dev/null || echo "0") - total_duration_ms=$(echo "$response" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['total_duration_ms'])" 2>/dev/null || echo "0") - - # Calculate throughput - local prompt_tps=0 - if [[ "$prompt_eval_duration_ms" -gt 0 ]]; then - prompt_tps=$(python3 -c "print(f'{($prompt_tokens / $prompt_eval_duration_ms) * 1000:.1f}')") - fi - - echo " Prompt tokens: $prompt_tokens" - echo " Prompt eval: ${prompt_eval_duration_ms}ms" - echo " Prompt TPS: $prompt_tps tok/s" - echo " Total duration: ${total_duration_ms}ms" - - # Kill server - kill "$server_pid" 2>/dev/null || true - sleep 2 - - echo " Done." - echo "---" - - # Output structured results for parsing - echo "RESULT|$label|$prompt_file|$prompt_tokens|$prompt_eval_duration_ms|$total_duration_ms|$prompt_tps" -} - -echo "╔══════════════════════════════════════════════════════════╗" -echo "║ N-gram Mod Benchmark — frank-dragoon ║" -echo "║ STRIX ROCmFP4 · 64k context · two prompts ║" -echo "╚══════════════════════════════════════════════════════════╝" -echo "" -echo "Host: $HOSTNAME" -echo "Model: $MODEL" -echo "Build: $(basename "$LLAMA_BIN")" -echo "Context: $CTX_SIZE" -echo "" - -# Run baseline (no spec) -echo "=== BASELINE (no speculative decoding) ===" -echo "" - -BASELINE_CODE_RESULT=$(run_single_test $PORT_BASELINE "none" "$PROMPT_CODE" "Baseline-Code" 24 4) -BASELINE_PROSE_RESULT=$(run_single_test $PORT_NGRAM "none" "$PROMPT_PROSE" "Baseline-Prose" 24 4) - -echo "" -echo "=== NGRAM-MOD (speculative decoding enabled) ===" -echo "" - -# Run n-gram mod -NGRAM_CODE_RESULT=$(run_single_test $PORT_NGRAM "ngram-mod" "$PROMPT_CODE" "Ngram-Code" 24 4) -NGRAM_PROSE_RESULT=$(run_single_test $PORT_BASELINE "ngram-mod" "$PROMPT_PROSE" "Ngram-Prose" 24 4) - -echo "" -echo "╔══════════════════════════════════════════════════════════╗" -echo "║ RESULTS SUMMARY ║" -echo "╚══════════════════════════════════════════════════════════╝" -echo "" - -# Parse and display results -python3 << 'PYEOF' -import sys - -results = [] -for line in sys.stdin: - line = line.strip() - if not line.startswith("RESULT|"): - continue - parts = line.split("|") - if len(parts) != 8: - continue - label, prompt_file, prompt_tokens, prompt_eval_ms, total_ms, tps = parts[1], parts[2], int(parts[3]), int(parts[4]), int(parts[5]), float(parts[6]) - results.append({ - 'label': label, - 'prompt_file': prompt_file, - 'prompt_tokens': prompt_tokens, - 'prompt_eval_ms': prompt_eval_ms, - 'total_ms': total_ms, - 'tps': tps - }) - -# Compare baseline vs ngram for each prompt type -for code_result in results: - if not code_result['label'].startswith('Ngram-'): - continue - prompt_type = code_result['label'].replace('Ngram-', '').replace('Ngram', 'Baseline').replace('-', '-') - baseline = next((r for r in results if r['label'] == 'Baseline-' + prompt_type), None) - if not baseline: - continue - - spec = code_result['label'] == 'Ngram-' + prompt_type - - print(f"Prompt: {prompt_type}") - print(f" Tokens: {code_result['prompt_tokens']}") - if spec: - print(f" Spec: ngram-mod (n_max=24, n_min=4)") - print(f" Prompt: {code_result['prompt_eval_ms']}ms | {code_result['tps']:>10.1f} tok/s") - baseline_tps = baseline['tps'] - if baseline_tps > 0: - improvement = ((code_result['tps'] - baseline_tps) / baseline_tps) * 100 - print(f" Speedup: {improvement:+.1f}% vs baseline ({baseline_tps:.1f} tok/s)") - else: - print(f" Spec: none (baseline)") - print(f" Prompt: {baseline['prompt_eval_ms']}ms | {baseline['tps']:>10.1f} tok/s") - print() -PYEOF - -echo "" -echo "Raw data lines above (RESULT|... format) can be parsed for automation." -echo "" diff --git a/scripts/.AGENTTRASH/bench-spec-decode.py.ik-llama.cpp b/scripts/.AGENTTRASH/bench-spec-decode.py.ik-llama.cpp deleted file mode 100755 index 68f3313..0000000 --- a/scripts/.AGENTTRASH/bench-spec-decode.py.ik-llama.cpp +++ /dev/null @@ -1,229 +0,0 @@ -#!/usr/bin/env python3 -"""Benchmark decode throughput across speculative decoding configs. - -Sweeps 4 configs: none, ngram-mod, draft-mtp, draft-mtp+ngram-mod. -Each config generates 16k tokens from a minimal prompt at temp 0. - -Runs ON the target host (Dankclaw). Service management uses local sudo. -Usage: - python3 bench-spec-decode.py [--max-tokens N] [--host H] [--port P] -""" -import json -import subprocess -import sys -import time -import urllib.request -import urllib.error - -SERVICE = "frank-dragoon" -ENV_FILE = "/home/dank/serverAppConfigs/scripts/frank-dragoon.env" -ALIAS = "frank-dragoon" -DEFAULT_HOST = "localhost" -DEFAULT_PORT = 7770 -DEFAULT_MAX_TOKENS = 16384 -HEALTH_TIMEOUT = 1200 # 20 min for cold load -HEALTH_INTERVAL = 5 - -PROMPT = ( - "Write a comprehensive, detailed essay about the complete history of " - "computing, from the earliest mechanical calculators through modern AI. " - "Cover every major era in detail, including key figures, machines, and " - "breakthroughs. Be as thorough as possible." -) - -CONFIGS = [ - ("none", "none"), - ("ngram-mod", "ngram-mod"), - ("draft-mtp", "draft-mtp"), - ("draft-mtp+ngram-mod", "draft-mtp,ngram-mod"), -] - - -def local(cmd): - """Run a command locally (the script runs on the target host).""" - r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=120) - if r.returncode != 0: - print(f" WARNING: {cmd} -> exit {r.returncode}: {r.stderr.strip()}", - file=sys.stderr) - return r - - -def sed_spec_type(value): - local(f"sudo sed -i 's/^SPEC_TYPE=.*/SPEC_TYPE={value}/' {ENV_FILE}") - - -def wait_health(host, port, timeout=HEALTH_TIMEOUT): - start = time.time() - while time.time() - start < timeout: - try: - resp = urllib.request.urlopen( - f"http://{host}:{port}/health", timeout=10 - ) - if resp.status == 200: - return True - except (urllib.error.URLError, urllib.error.HTTPError, ConnectionError, - OSError): - pass - time.sleep(HEALTH_INTERVAL) - return False - - -def benchmark(host, port, alias, max_tokens): - body = json.dumps({ - "model": alias, - "messages": [{"role": "user", "content": PROMPT}], - "max_tokens": max_tokens, - "temperature": 0, - "stream": False, - }).encode() - req = urllib.request.Request( - f"http://{host}:{port}/v1/chat/completions", - data=body, - headers={"Content-Type": "application/json"}, - ) - t0 = time.time() - resp = urllib.request.urlopen(req, timeout=1200) - wall = time.time() - t0 - data = json.loads(resp.read()) - return data, wall - - -def main(): - import argparse - ap = argparse.ArgumentParser(description=__doc__) - ap.add_argument("--host", default=DEFAULT_HOST) - ap.add_argument("--port", type=int, default=DEFAULT_PORT) - ap.add_argument("--max-tokens", type=int, default=DEFAULT_MAX_TOKENS) - args = ap.parse_args() - - # Read original SPEC_TYPE from the env file - r = local(f"grep '^SPEC_TYPE=' {ENV_FILE}") - original_spec = r.stdout.strip() - orig_val = (original_spec.split("=", 1)[1] - if "=" in original_spec else "draft-mtp,ngram-mod") - print(f"Original: {original_spec}") - print(f"Max tokens: {args.max_tokens}") - print(f"Configs: {[c[0] for c in CONFIGS]}") - print() - - # Stop + mask the production service - print("Stopping + masking production service...") - local(f"sudo systemctl stop {SERVICE}") - local(f"sudo systemctl mask {SERVICE}") - time.sleep(2) - - results = [] - for name, spec_type in CONFIGS: - print(f"\n{'='*60}") - print(f"Config: {name} (SPEC_TYPE={spec_type})") - print(f"{'='*60}") - - # Set SPEC_TYPE - sed_spec_type(spec_type) - print(f"Set SPEC_TYPE={spec_type}") - - # Start the service - local(f"sudo systemctl unmask {SERVICE}") - local(f"sudo systemctl start {SERVICE}") - print("Starting service...") - - # Wait for health - print(f"Waiting for health (up to {HEALTH_TIMEOUT}s)...") - if not wait_health(args.host, args.port): - print(f"FAILED: service did not become healthy for {name}") - results.append({ - "config": name, "spec_type": spec_type, - "error": "health timeout", - }) - local(f"sudo systemctl stop {SERVICE}") - local(f"sudo systemctl mask {SERVICE}") - continue - - print("Healthy. Running benchmark...") - - # Run benchmark - try: - data, wall = benchmark( - args.host, args.port, ALIAS, args.max_tokens - ) - except Exception as e: - print(f"FAILED: {e}") - results.append({ - "config": name, "spec_type": spec_type, - "error": str(e), - }) - local(f"sudo systemctl stop {SERVICE}") - local(f"sudo systemctl mask {SERVICE}") - continue - - timings = data.get("timings", {}) - usage = data.get("usage", {}) - pred_n = timings.get("predicted_n", usage.get("completion_tokens", 0)) - pred_ms = timings.get("predicted_ms", 0) - pred_ps = timings.get("predicted_per_second", 0) - prompt_n = timings.get("prompt_n", usage.get("prompt_tokens", 0)) - prompt_ms = timings.get("prompt_ms", 0) - - print(f" Generated: {pred_n} tokens") - print(f" Wall time: {wall:.1f}s") - print(f" Decode: {pred_ps:.2f} tok/s") - print(f" Prefill: {prompt_n} tokens in {prompt_ms:.0f}ms" - f" ({prompt_n / max(prompt_ms, 1) * 1000:.1f} tok/s)") - - results.append({ - "config": name, - "spec_type": spec_type, - "predicted_tokens": pred_n, - "predicted_ms": pred_ms, - "predicted_per_second": pred_ps, - "prompt_tokens": prompt_n, - "prompt_ms": prompt_ms, - "wall_time": wall, - }) - - # Stop + mask between configs - local(f"sudo systemctl stop {SERVICE}") - local(f"sudo systemctl mask {SERVICE}") - time.sleep(2) - - # Restore original SPEC_TYPE - sed_spec_type(orig_val) - print(f"\nRestoring SPEC_TYPE={orig_val}") - print("Unmasking + starting production service...") - local(f"sudo systemctl unmask {SERVICE}") - local(f"sudo systemctl start {SERVICE}") - - # Health check - if wait_health(args.host, args.port): - print("Production service healthy.") - else: - print("WARNING: production service not healthy after restore!") - - # Print results table - print(f"\n{'='*70}") - print("RESULTS") - print(f"{'='*70}") - print(f"{'Config':<30} {'Tokens':>8} {'Wall(s)':>10} {'Decode tok/s':>14} " - f"{'Error':>20}") - print(f"{'-'*30} {'-'*8} {'-'*10} {'-'*14} {'-'*20}") - for r in results: - if "error" in r: - print(f"{r['config']:<30} {'':>8} {'':>10} {'':>14} " - f"{r['error']:>20}") - else: - print(f"{r['config']:<30} {r['predicted_tokens']:>8} " - f"{r['wall_time']:>10.1f} {r['predicted_per_second']:>14.2f} " - f"{'':>20}") - print(f"{'='*70}") - - # Save JSON results - ts = time.strftime("%Y%m%d-%H%M%S") - out = f"/tmp/bench-spec-decode-{ts}.json" - with open(out, "w") as f: - json.dump({"results": results, "max_tokens": args.max_tokens, - "timestamp": ts}, f, indent=2) - print(f"Results saved: {out}") - - -if __name__ == "__main__": - main() diff --git a/scripts/.AGENTTRASH/bench-vllm-corpus.py b/scripts/.AGENTTRASH/bench-vllm-corpus.py deleted file mode 100755 index df0e7cf..0000000 --- a/scripts/.AGENTTRASH/bench-vllm-corpus.py +++ /dev/null @@ -1,85 +0,0 @@ -#!/usr/bin/env python3 -"""bench-vllm-corpus.py — build a code-shaped benchmark corpus from the repo. - -Why this exists: `vllm bench serve --dataset-name random` produces synthetic -token soup, and speculative decoding accepts it nothing like real code — measured -on frank-medium, random prompts accepted 2.9-3.6 tokens/update against the ~5.8 -the upstream reference reports on a code mix, which makes the random corpus -under-report throughput by a wide margin and would pick the wrong SPEC depth. - -The prompts are the shape agents actually send: a repository artifact plus an -instruction that requires reading it. Output length is fixed at bench time -(`--custom-output-len`), so the corpus carries prompts only. - -Usage: - bench-vllm-corpus.py [repo-root] [out.jsonl] - -Then feed it to the endpoint (see scripts/bench-vllm-serve.sh, which passes the -tokenizer the client needs and salts prompts so no run inherits a warm prefix -cache). Methodology and the numbers these corpora produced are in -serverAppConfigs-docs/LLM/frank-medium-radiance-mxfp4.md. -""" - -from __future__ import annotations - -import json -import pathlib -import sys - -REPO = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else "/home/dank/serverAppConfigs") -OUT = pathlib.Path(sys.argv[2] if len(sys.argv) > 2 else "/home/dank/bench/corpus-code.jsonl") - -TARGETS = [ - "scripts/deploy-remote.sh", - "scripts/deploy-plan.py", - "scripts/deploy.sh", - "dankserver/frank-medium/radiance-entrypoint.sh", - "dankserver/frank-medium/quadlet/frank-medium-a.container", - "dankserver/frank-medium/proxy/affinity-proxy.py", - "dankserver/frank-medium/proxy/routing.py", - "tests/frank_medium_quadlet.bats", - "tests/deployment_scoping.bats", - "dankclaw/hermes/quadlet/hermes.container", - "dankclaw/anubis/quadlet/anubis.container", - "shared/prometheus/prometheus.yml", - "shared/prometheus/rules/llm-fleet.yml", - "shared/loki/loki-config.yaml", - "dankclaw/compose.yaml", - "dankserver/copyparty/quadlet/copyparty.container", -] - -PROMPTS = [ - "Read this repository artifact and summarise what it does, then list every " - "assumption it makes about the host it runs on.\n\n```\n{body}\n```", - "Here is a file from our homelab repository. Identify any bug, race or " - "missing error path, and show the minimal patch that fixes it.\n\n```\n{body}\n```", - "Explain this configuration to a new operator, then rewrite it with " - "comments that state the constraint each line protects.\n\n```\n{body}\n```", - "This file changed in a recent deploy. Write the changelog entry and a test " - "that would fail if the change regressed.\n\n```\n{body}\n```", -] - -MAX_LINES = 220 - - -def main() -> int: - samples = [] - for i, rel in enumerate(TARGETS): - path = REPO / rel - if not path.is_file(): - continue - body = "\n".join(path.read_text(errors="replace").splitlines()[:MAX_LINES]) - samples.append({"prompt": PROMPTS[i % len(PROMPTS)].format(body=body)}) - if not samples: - print(f"no corpus sources found under {REPO}", file=sys.stderr) - return 1 - OUT.parent.mkdir(parents=True, exist_ok=True) - with OUT.open("w") as fh: - for sample in samples: - fh.write(json.dumps(sample) + "\n") - print(f"wrote {len(samples)} prompts to {OUT}") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/.AGENTTRASH/bench-vllm-serve.sh b/scripts/.AGENTTRASH/bench-vllm-serve.sh deleted file mode 100755 index 97cae60..0000000 --- a/scripts/.AGENTTRASH/bench-vllm-serve.sh +++ /dev/null @@ -1,63 +0,0 @@ -#!/bin/bash -# bench-vllm-serve.sh — concurrency sweep against a vLLM endpoint. -# -# Usage: bench-vllm-serve.sh <tag> <port> [concurrency ...] -# e.g. bench-vllm-serve.sh baseline 7770 1 4 8 -# -# Runs `vllm bench serve` from inside the serving image (so the client matches -# the server build) through an OpenAI-compatible endpoint, on the code corpus -# scripts/bench-vllm-corpus.py writes. -# -# Two traps this script exists to encode, both of which produced silent garbage -# during the 2026-09-16 frank-medium work: -# -# 1. The tokenizer must be passed explicitly and /models mounted. With only -# `--model frank-medium` the client tries to resolve that alias on -# HuggingFace and dies before sending a request — a run that looks like a -# benchmark and reports nothing. -# 2. `--ignore-eos` is deliberate: it measures decode throughput at a fixed -# output length. Drop it when you care about real stop behaviour, but then -# do not compare the numbers with anything measured under it. -# -# Also note the corpora are code-shaped on purpose (see the generator's header): -# synthetic random prompts misreport speculative acceptance badly. -# -# Methodology, and the numbers these runs produced on 2x R9700, are in -# serverAppConfigs-docs/LLM/frank-medium-radiance-mxfp4.md. -set -u - -TAG=${1:?usage: bench-vllm-serve.sh <tag> <port> [concurrency ...]} -PORT=${2:?usage: bench-vllm-serve.sh <tag> <port> [concurrency ...]} -shift 2 -CONCS=("$@") -[ "${#CONCS[@]}" -eq 0 ] && CONCS=(1 4 8) - -IMG=${IMG:-localhost/radiance-vllm:0.9.3} -MODELS_DIR=${MODELS_DIR:-/home/dank/models} -TOK=${TOK:-/models/Qwen3.8-27B-Uncensored-MXFP4-awq} -BENCH_DIR=${BENCH_DIR:-/home/dank/bench} -CORPUS=${CORPUS:-$BENCH_DIR/corpus-code.jsonl} -OUT=${OUT:-$BENCH_DIR/$TAG} -mkdir -p "$OUT" - -if [ ! -f "$CORPUS" ]; then - echo "corpus $CORPUS is missing; run scripts/bench-vllm-corpus.py first" >&2 - exit 1 -fi - -for c in "${CONCS[@]}"; do - echo "== $TAG conc=$c (port $PORT)" - podman run --rm --network=host -v "$MODELS_DIR":/models:ro -v "$BENCH_DIR":/bench:ro \ - -e HF_HUB_OFFLINE=1 --entrypoint bash "$IMG" -lc " - vllm bench serve --backend openai-chat \ - --base-url http://127.0.0.1:$PORT --endpoint /v1/chat/completions \ - --model frank-medium --tokenizer $TOK \ - --dataset-name custom --dataset-path /bench/$(basename "$CORPUS") \ - --custom-output-len 512 --num-prompts 16 --max-concurrency $c \ - --disable-shuffle --seed 0 --ignore-eos \ - --percentile-metrics ttft,tpot --metric-percentiles 50,99 \ - --disable-tqdm 2>&1 | grep -E 'Successful requests|Benchmark duration|Output token throughput|Total Token throughput|Mean TTFT|P50 TTFT|P50 TPOT|Acceptance rate|Acceptance length' - " 2>&1 | sed 's/^/ /' - echo -done -echo "results for $TAG written under $OUT" diff --git a/scripts/.AGENTTRASH/chat-template.jinja.ik-llama-qwen-sharp b/scripts/.AGENTTRASH/chat-template.jinja.ik-llama-qwen-sharp deleted file mode 100644 index 0a2996e..0000000 --- a/scripts/.AGENTTRASH/chat-template.jinja.ik-llama-qwen-sharp +++ /dev/null @@ -1,453 +0,0 @@ -{%- set template_version = "qwen3.8-froggeric-v22.4.0" %} -{%- set _tool_format = tool_call_format if tool_call_format is defined else 'xml' %} -{%- set image_count = namespace(value=0) %} -{%- set video_count = namespace(value=0) %} -{%- set add_vision_id = add_vision_id if add_vision_id is defined else false %} -{%- set enable_thinking = enable_thinking if enable_thinking is defined else true %} -{%- set auto_disable_thinking_with_tools = auto_disable_thinking_with_tools if auto_disable_thinking_with_tools is defined else false %} -{%- if preserve_reasoning is defined and preserve_reasoning is not none %} - {%- set _preserve_thinking = preserve_reasoning %} -{%- elif preserve_thinking is defined and preserve_thinking is not none %} - {%- set _preserve_thinking = preserve_thinking %} -{%- else %} - {%- set _preserve_thinking = true %} -{%- endif %} -{%- set max_tool_arg_chars = max_tool_arg_chars if max_tool_arg_chars is defined else 0 %} -{%- set max_tool_response_chars = max_tool_response_chars if max_tool_response_chars is defined else 0 %} -{%- set _default_reasoning_effort = 'medium' %} -{%- set _has_tools = (tools is defined and tools and tools is iterable and tools is not mapping) %} -{%- set _effort_raw = (reasoning_effort | string | lower) if reasoning_effort is defined and reasoning_effort is not none else _default_reasoning_effort %} -{%- set _initial_thinking = enable_thinking %} -{%- if _effort_raw in ('none', 'off') %} - {%- set _initial_thinking = false %} - {%- set _initial_effort = 'medium' %} -{%- elif _effort_raw in ('minimal', 'low') %} - {%- set _initial_effort = 'low' %} -{%- elif _effort_raw in ('high', 'xhigh', 'max', 'ultracode', 'extreme') %} - {%- set _initial_effort = 'xhigh' %} -{%- else %} - {%- set _initial_effort = 'medium' %} -{%- endif %} -{%- set ns_state = namespace(thinking=_initial_thinking, effort=_initial_effort) %} -{%- if auto_disable_thinking_with_tools and _has_tools %} - {%- set ns_state.thinking = false %} -{%- endif %} -{%- for msg in messages %} - {%- if msg.role == 'system' or msg.role == 'developer' or msg.role == 'user' %} - {%- if msg.content is string %} - {%- if '<|think_off|>' in msg.content %} - {%- set ns_state.thinking = false %} - {%- elif '<|think_on|>' in msg.content %} - {%- set ns_state.thinking = true %} - {%- elif '<|think_xhigh|>' in msg.content or '<|think_high|>' in msg.content or '<|think_ultracode|>' in msg.content or '<|think_extreme|>' in msg.content or '<|think_max|>' in msg.content %} - {%- set ns_state.thinking = true %} - {%- set ns_state.effort = 'xhigh' %} - {%- elif '<|think_low|>' in msg.content or '<|think_minimal|>' in msg.content %} - {%- set ns_state.thinking = true %} - {%- set ns_state.effort = 'low' %} - {%- elif '<|think_medium|>' in msg.content %} - {%- set ns_state.thinking = true %} - {%- set ns_state.effort = 'medium' %} - {%- endif %} - {%- elif msg.content is iterable and msg.content is not mapping %} - {%- for item in msg.content %} - {%- if item is string %} - {%- set _item_text = item %} - {%- elif item is mapping and 'text' in item and item.text is string %} - {%- set _item_text = item.text %} - {%- else %} - {%- set _item_text = '' %} - {%- endif %} - {%- if _item_text %} - {%- if '<|think_off|>' in _item_text %} - {%- set ns_state.thinking = false %} - {%- elif '<|think_on|>' in _item_text %} - {%- set ns_state.thinking = true %} - {%- elif '<|think_xhigh|>' in _item_text or '<|think_high|>' in _item_text or '<|think_ultracode|>' in _item_text or '<|think_extreme|>' in _item_text or '<|think_max|>' in _item_text %} - {%- set ns_state.thinking = true %} - {%- set ns_state.effort = 'xhigh' %} - {%- elif '<|think_low|>' in _item_text or '<|think_minimal|>' in _item_text %} - {%- set ns_state.thinking = true %} - {%- set ns_state.effort = 'low' %} - {%- elif '<|think_medium|>' in _item_text %} - {%- set ns_state.thinking = true %} - {%- set ns_state.effort = 'medium' %} - {%- endif %} - {%- endif %} - {%- endfor %} - {%- endif %} - {%- endif %} -{%- endfor %} -{%- set reasoning_instructions = '' %} -{%- if ns_state.thinking %} - {%- if ns_state.effort == 'xhigh' %} - {%- set reasoning_instructions = 'Reasoning effort is set to xhigh. Please think carefully through the task, validate key assumptions, consider plausible alternatives, and prioritize correctness, consistency, and clarity in the final answer.' %} - {%- elif ns_state.effort == 'low' %} - {%- set reasoning_instructions = 'Reasoning effort is set to low. Keep your thinking brief and focused, moving directly to the conclusion without unnecessary elaboration.' %} - {%- endif %} -{%- endif %} -{%- macro render_content(content, do_vision_count, is_system_content=false) %} - {%- if content is string %} - {{- content }} - {%- elif content is iterable and content is not mapping %} - {%- for item in content %} - {%- if item is mapping %} - {%- if item.type == 'image' or 'image' in item or 'image_url' in item %} - {%- if is_system_content %} - {{- raise_exception('System message cannot contain images.') }} - {%- endif %} - {%- if do_vision_count %} - {%- set image_count.value = image_count.value + 1 %} - {%- endif %} - {%- if add_vision_id %} - {{- 'Picture ' ~ image_count.value ~ ': ' }} - {%- endif %} - {{- '<|vision_start|><|image_pad|><|vision_end|>' }} - {%- elif item.type == 'video' or 'video' in item %} - {%- if is_system_content %} - {{- raise_exception('System message cannot contain videos.') }} - {%- endif %} - {%- if do_vision_count %} - {%- set video_count.value = video_count.value + 1 %} - {%- endif %} - {%- if add_vision_id %} - {{- 'Video ' ~ video_count.value ~ ': ' }} - {%- endif %} - {{- '<|vision_start|><|video_pad|><|vision_end|>' }} - {%- elif 'text' in item %} - {{- item.text }} - {%- else %} - {{- raise_exception('Unexpected item type in content.') }} - {%- endif %} - {%- else %} - {{- item | string }} - {%- endif %} - {%- endfor %} - {%- elif content is none or content is undefined %} - {{- '' }} - {%- else %} - {{- raise_exception('Unexpected content type.') }} - {%- endif %} -{%- endmacro %} -{%- if not messages %} - {{- raise_exception('No messages provided.') }} -{%- endif %} -{%- set head = namespace(count=0, seen_non_system=false) %} -{%- for message in messages %} - {%- set _is_sys = (message.role == 'system' or message.role == 'developer') %} - {%- if _is_sys and not head.seen_non_system %} - {%- set head.count = head.count + 1 %} - {%- else %} - {%- set head.seen_non_system = true %} - {%- endif %} -{%- endfor %} -{%- set sys_state = namespace(content='') %} -{%- for message in messages[:head.count] %} - {%- set _part = render_content(message.content, false, true) | trim %} - {%- if '<|think_off|>' in _part %}{%- set _part = _part.split('<|think_off|>') | join('') | trim %}{%- endif %} - {%- if '<|think_on|>' in _part %}{%- set _part = _part.split('<|think_on|>') | join('') | trim %}{%- endif %} - {%- if '<|think_xhigh|>' in _part %}{%- set _part = _part.split('<|think_xhigh|>') | join('') | trim %}{%- endif %} - {%- if '<|think_high|>' in _part %}{%- set _part = _part.split('<|think_high|>') | join('') | trim %}{%- endif %} - {%- if '<|think_ultracode|>' in _part %}{%- set _part = _part.split('<|think_ultracode|>') | join('') | trim %}{%- endif %} - {%- if '<|think_extreme|>' in _part %}{%- set _part = _part.split('<|think_extreme|>') | join('') | trim %}{%- endif %} - {%- if '<|think_max|>' in _part %}{%- set _part = _part.split('<|think_max|>') | join('') | trim %}{%- endif %} - {%- if '<|think_medium|>' in _part %}{%- set _part = _part.split('<|think_medium|>') | join('') | trim %}{%- endif %} - {%- if '<|think_low|>' in _part %}{%- set _part = _part.split('<|think_low|>') | join('') | trim %}{%- endif %} - {%- if '<|think_minimal|>' in _part %}{%- set _part = _part.split('<|think_minimal|>') | join('') | trim %}{%- endif %} - {%- if _part %} - {%- if sys_state.content %} - {%- set sys_state.content = sys_state.content ~ '\n\n' ~ _part %} - {%- else %} - {%- set sys_state.content = _part %} - {%- endif %} - {%- endif %} -{%- endfor %} -{%- set _sc = sys_state.content %} -{%- set _msgs = messages[head.count:] %} -{%- if ns_state.thinking %} - {%- set _terse_lead = 'Answer directly, after thinking. Lead with the answer, then only what it needs to be correct and usable.' %} -{%- else %} - {%- set _terse_lead = 'Answer directly and concisely. Give the answer with only what it needs to be correct and usable.' %} -{%- endif %} -{%- set _terse_core %} -Never: open with preamble or pleasantries; restate the question; add filler transitions; hedge with niceties; or repeat a point you've already made. -Always: keep essential steps, caveats, uncertainties, and specifics — never drop correctness or a needed warning for brevity. Keep the final answer lean. Use the least structure that conveys it (plain prose when short; lists or code only when they earn their place). If genuinely uncertain, say so and explain why — never omit uncertainty for the sake of brevity. -If a user request is genuinely ambiguous, ask a sharp question, don't guess. -{%- endset %} -{%- set _terse = _terse_lead ~ '\n' ~ (_terse_core | trim) %} -{#- `terse` chat-template kwarg: default true, so behaviour is unchanged unless a caller opts out. - Pass {"terse": false} via chat_template_kwargs to serve the model with only its own system - prompt -- useful for A/B-ing the terseness effect, or when a downstream prompt conflicts. #} -{%- set _terse_on = terse if terse is defined else true %} -{%- if _terse_on %} - {%- if not _sc %} - {%- set _sc = _terse | trim %} - {%- else %} - {%- set _sc = (_sc | trim) ~ '\n\n' ~ (_terse | trim) %} - {%- endif %} -{%- endif %} -{%- if _has_tools %} - {{- '<|im_start|>system\n' }} - {%- if reasoning_instructions %} - {{- reasoning_instructions + '\n\n' }} - {%- endif %} - {{- '# Tools\n\nYou have access to the following functions:\n\n<tools>' }} - {%- for tool in tools %} - {{- '\n' }} - {{- tool | tojson }} - {%- endfor %} - {{- '\n</tools>' }} - {%- if _tool_format == 'json' %} - {{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n' }}{%- if ns_state.thinking %}{{- '<think>\nBrief explanation of tool call\n</think>\n' }}{%- endif %}{{- '<tool_call>\n{"name": "example_function_name", "arguments": {"example_parameter_1": "value_1", "example_parameter_2": "This is the value for the second parameter"}}\n</tool_call>\n\n<IMPORTANT>\nReminder:\n' }}{%- if ns_state.thinking %}{{- '- You can use the <think></think> block to plan your next tool call OR to synthesize data and formulate your final response to the user.\n- ALL explanation and reasoning MUST be placed strictly inside the <think></think> block.\n' }}{%- endif %}{{- '- Function calls MUST follow the specified format: a single JSON object with "name" and "arguments" keys inside <tool_call></tool_call> XML tags.\n- If you choose to call a tool, you MUST output the <tool_call> block IMMEDIATELY' }}{%- if ns_state.thinking %}{{- ' after thinking' }}{%- endif %}{{- ', with NO conversational text before it.\n- The <tool_call> tag MUST be at the very beginning of a new line, with NO spaces or indentation before it.\n- To call multiple functions, output a separate, completely closed <tool_call></tool_call> block for EACH function. Do NOT nest <tool_call> blocks.\n- If you have all necessary data, provide your final answer directly to the user without any tool call.\n</IMPORTANT>' }} - {%- else %} - {{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n' }}{%- if ns_state.thinking %}{{- '<think>\nBrief explanation of tool call\n</think>\n' }}{%- endif %}{{- '<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n' }}{%- if ns_state.thinking %}{{- '- You can use the <think></think> block to plan your next tool call OR to synthesize data and formulate your final response to the user.\n- ALL explanation and reasoning MUST be placed strictly inside the <think></think> block.\n' }}{%- endif %}{{- '- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags.\n- If you choose to call a tool, you MUST output the <tool_call> block IMMEDIATELY' }}{%- if ns_state.thinking %}{{- ' after thinking' }}{%- endif %}{{- ', with NO conversational text before it.\n- The <tool_call> and <function> tags MUST be at the very beginning of a new line, with NO spaces or indentation before them.\n- To call multiple functions, output a separate, completely closed <tool_call></tool_call> block for EACH function. Do NOT nest <tool_call> blocks.\n- If you have all necessary data, provide your final answer directly to the user without any tool call.\n</IMPORTANT>' }} - {%- endif %} - {%- if _sc %} - {{- '\n\n' + _sc }} - {%- endif %} - {{- '<|im_end|>\n' }} -{%- else %} - {%- if _sc %} - {{- '<|im_start|>system\n' + (reasoning_instructions + '\n\n' if reasoning_instructions else '') + _sc + '<|im_end|>\n' }} - {%- elif reasoning_instructions %} - {{- '<|im_start|>system\n' + reasoning_instructions + '<|im_end|>\n' }} - {%- endif %} -{%- endif %} -{%- set _last_idx = _msgs | length - 1 %} -{%- set ns = namespace(multi_step_tool=true, last_query_index=_last_idx) %} -{%- for message in _msgs[::-1] %} - {%- set index = (_msgs | length - 1) - loop.index0 %} - {%- if ns.multi_step_tool and message.role == 'user' %} - {%- set _rc = render_content(message.content, false) | trim %} - {%- if not (_rc.startswith('<tool_response>') and _rc.endswith('</tool_response>')) %} - {%- set ns.multi_step_tool = false %} - {%- set ns.last_query_index = index %} - {%- endif %} - {%- endif %} -{%- endfor %} -{%- if ns.multi_step_tool %} - {%- if _last_idx > 50 %} - {%- set ns.last_query_index = _last_idx %} - {%- else %} - {%- set ns.last_query_index = 0 %} - {%- endif %} -{%- endif %} -{%- set ns2 = namespace(prev_role='', consecutive_failures=0) %} -{%- for message in _msgs %} - {%- set is_system = (message.role == "system" or message.role == "developer") %} - {%- set content = render_content(message.content, true, is_system) | trim %} - {%- if is_system or message.role == 'user' %} - {%- if '<|think_off|>' in content %}{%- set content = content.split('<|think_off|>') | join('') | trim %}{%- endif %} - {%- if '<|think_on|>' in content %}{%- set content = content.split('<|think_on|>') | join('') | trim %}{%- endif %} - {%- if '<|think_xhigh|>' in content %}{%- set content = content.split('<|think_xhigh|>') | join('') | trim %}{%- endif %} - {%- if '<|think_high|>' in content %}{%- set content = content.split('<|think_high|>') | join('') | trim %}{%- endif %} - {%- if '<|think_ultracode|>' in content %}{%- set content = content.split('<|think_ultracode|>') | join('') | trim %}{%- endif %} - {%- if '<|think_extreme|>' in content %}{%- set content = content.split('<|think_extreme|>') | join('') | trim %}{%- endif %} - {%- if '<|think_max|>' in content %}{%- set content = content.split('<|think_max|>') | join('') | trim %}{%- endif %} - {%- if '<|think_medium|>' in content %}{%- set content = content.split('<|think_medium|>') | join('') | trim %}{%- endif %} - {%- if '<|think_low|>' in content %}{%- set content = content.split('<|think_low|>') | join('') | trim %}{%- endif %} - {%- if '<|think_minimal|>' in content %}{%- set content = content.split('<|think_minimal|>') | join('') | trim %}{%- endif %} - {%- endif %} - {%- if is_system %} - {{- '<|im_start|>system\n' + content + '<|im_end|>\n' }} - {%- elif message.role == 'user' %} - {%- set ns2.consecutive_failures = 0 %} - {{- '<|im_start|>user\n' + content + '<|im_end|>\n' }} - {%- elif message.role == 'assistant' %} - {%- set reasoning_content = '' %} - {%- set _explicit_reasoning = '' %} - {%- if message.reasoning_content is defined and message.reasoning_content is not none %} - {%- if message.reasoning_content is string %} - {%- set _explicit_reasoning = message.reasoning_content %} - {%- else %} - {%- set _explicit_reasoning = message.reasoning_content | string %} - {%- endif %} - {%- elif message.thinking is defined and message.thinking is not none %} - {%- if message.thinking is string %} - {%- set _explicit_reasoning = message.thinking %} - {%- else %} - {%- set _explicit_reasoning = message.thinking | string %} - {%- endif %} - {%- elif message.reasoning is defined and message.reasoning is not none %} - {%- if message.reasoning is string %} - {%- set _explicit_reasoning = message.reasoning %} - {%- else %} - {%- set _explicit_reasoning = message.reasoning | string %} - {%- endif %} - {%- endif %} - {%- if _explicit_reasoning %} - {%- set _lead_end = '' %} - {%- if content.startswith('<think>') and '</think>' in content %} - {%- set _lead_end = '</think>' %} - {%- elif content.startswith('<thinking>') and '</thinking>' in content %} - {%- set _lead_end = '</thinking>' %} - {%- elif content.startswith('</think>') %} - {%- set _lead_end = '</think>' %} - {%- elif content.startswith('</thinking>') %} - {%- set _lead_end = '</thinking>' %} - {%- endif %} - {%- if _lead_end %} - {%- set content = content.split(_lead_end)[-1].lstrip('\n') %} - {%- endif %} - {%- set reasoning_content = _explicit_reasoning %} - {%- else %} - {%- set _think_end = '' %} - {%- if content.startswith('</think>') %} - {%- set _think_end = '</think>' %} - {%- elif content.startswith('</thinking>') %} - {%- set _think_end = '</thinking>' %} - {%- elif '\n</think>' in content %} - {%- set _think_end = '\n</think>' %} - {%- elif '\n</thinking>' in content %} - {%- set _think_end = '\n</thinking>' %} - {%- elif '\n</ think>' in content %} - {%- set _think_end = '\n</ think>' %} - {%- elif '\n</think >' in content %} - {%- set _think_end = '\n</think >' %} - {%- elif content.startswith('<think>') and '</think>' in content %} - {%- set _think_end = '</think>' %} - {%- elif content.startswith('<thinking>') and '</thinking>' in content %} - {%- set _think_end = '</thinking>' %} - {%- endif %} - {%- if _think_end %} - {%- if 'thinking' in _think_end %} - {%- set _think_start = '<thinking>' %} - {%- else %} - {%- set _think_start = '<think>' %} - {%- endif %} - {%- set reasoning_content = content.split(_think_end)[0].rstrip('\n') %} - {%- if _think_start in reasoning_content %} - {%- set reasoning_content = reasoning_content.split(_think_start)[-1].lstrip('\n') %} - {%- endif %} - {%- set content = content.split(_think_end)[-1].lstrip('\n') %} - {%- endif %} - {%- endif %} - {%- set reasoning_content = reasoning_content | trim %} - {%- if (_preserve_thinking or loop.index0 > ns.last_query_index) %} - {{- '<|im_start|>assistant\n<think>\n' + reasoning_content + '\n</think>\n\n' + content }} - {%- else %} - {{- '<|im_start|>assistant\n' + content }} - {%- endif %} - {%- if message.tool_calls is defined and message.tool_calls and message.tool_calls is iterable and message.tool_calls is not mapping %} - {%- for tool_call in message.tool_calls %} - {%- if tool_call.function is defined and tool_call.function is not none %} - {%- set tc = tool_call.function %} - {%- else %} - {%- set tc = tool_call %} - {%- endif %} - {%- set tc_name = tc.name if (tc.name is defined and tc.name is not none) else '' %} - {%- if _tool_format == 'json' %} - {%- if loop.first %} - {%- if content | trim %} - {{- '\n\n' }} - {%- endif %} - {%- else %} - {{- '\n' }} - {%- endif %} - {%- set _args = '{}' %} - {%- if tc.arguments is defined and tc.arguments is not none %} - {%- if tc.arguments is mapping %} - {%- set _args = tc.arguments | tojson %} - {%- elif tc.arguments is string %} - {%- if tc.arguments %} - {%- set _args = tc.arguments %} - {%- endif %} - {%- else %} - {%- set _args = tc.arguments | tojson %} - {%- endif %} - {%- endif %} - {{- '<tool_call>\n{"name": ' }}{{- tc_name | tojson }}{{- ', "arguments": ' }}{{- _args }}{{- '}\n</tool_call>' }} - {%- else %} - {%- if loop.first %} - {%- if content | trim %} - {{- '\n\n<tool_call>\n<function=' + tc_name + '>\n' }} - {%- else %} - {{- '<tool_call>\n<function=' + tc_name + '>\n' }} - {%- endif %} - {%- else %} - {{- '\n<tool_call>\n<function=' + tc_name + '>\n' }} - {%- endif %} - {%- if tc.arguments is defined and tc.arguments is not none %} - {%- if tc.arguments is mapping %} - {%- for args_name, args_value in tc.arguments.items() %} - {{- '<parameter=' + args_name + '>\n' }} - {%- if args_value is string %} - {%- set _av = args_value %} - {%- else %} - {%- set _av = args_value | tojson %} - {%- endif %} - {%- if max_tool_arg_chars > 0 and _av | length > max_tool_arg_chars %} - {{- _av[:max_tool_arg_chars] + '\n[TRUNCATED - original length ' ~ (_av | length | string) ~ ' chars]' }} - {%- else %} - {{- _av }} - {%- endif %} - {{- '\n</parameter>\n' }} - {%- endfor %} - {%- else %} - {%- if tc.arguments is string %} - {%- set _raw_args = tc.arguments %} - {%- else %} - {%- set _raw_args = tc.arguments | tojson %} - {%- endif %} - {%- if _raw_args %} - {%- if max_tool_arg_chars > 0 and _raw_args | length > max_tool_arg_chars %} - {{- _raw_args[:max_tool_arg_chars] + '\n[TRUNCATED - original length ' ~ (_raw_args | length | string) ~ ' chars]' }} - {%- else %} - {{- _raw_args }} - {%- endif %} - {%- endif %} - {%- endif %} - {%- endif %} - {{- '</function>\n</tool_call>' }} - {%- endif %} - {%- endfor %} - {%- endif %} - {{- '<|im_end|>\n' }} - {%- elif message.role == 'tool' %} - {%- set _content_lower = content | lower %} - {%- set _content_head = _content_lower[:120] %} - {%- set _is_code_or_grep = ('throw new ' in _content_lower or 'throw error' in _content_lower or 'console.error' in _content_lower or 'logger.error' in _content_lower or 'logging.error' in _content_lower or 'import ' in _content_head or 'def ' in _content_head or 'function ' in _content_head) %} - {%- set _exit_code_zero = ('exit code: 0' in _content_head or 'process exited with code 0' in _content_head) %} - {%- set _error_field_ok = ('"error": null' in _content_head or '"error":null' in _content_head or '"error": false' in _content_head or '"error":false' in _content_head or '"error": ""' in _content_head or '"error":""' in _content_head) %} - {%- set _strong_error = (('"error":' in _content_head and not _error_field_ok) or '"status": "error"' in _content_head or '"status":"error"' in _content_head or 'traceback (most recent call last):' in _content_head or 'command not found' in _content_head or 'invalid syntax' in _content_head or 'fatal:' in _content_head or (('exit code: ' in _content_head or 'process exited with code' in _content_head) and not _exit_code_zero) or _content_head.startswith('exception:') or _content_head.startswith('failed to ')) %} - {%- set _weak_error = ('error:' in _content_head or 'err!' in _content_head) %} - {%- set _weak_suppressed = ('$ ' in _content_head or 'took ' in _content_head or content | length >= 600) %} - {%- if not _is_code_or_grep and (_strong_error or (_weak_error and not _weak_suppressed)) %} - {%- set ns2.consecutive_failures = ns2.consecutive_failures + 1 %} - {%- else %} - {%- set ns2.consecutive_failures = 0 %} - {%- endif %} - {%- if ns2.prev_role != 'tool' %} - {{- '<|im_start|>user' }} - {%- endif %} - {%- if _tool_format != 'json' and max_tool_response_chars > 0 and content | length > max_tool_response_chars %} - {%- set content = content[:max_tool_response_chars] + '\n[TRUNCATED - original length ' ~ (content | length | string) ~ ' chars]' %} - {%- endif %} - {{- '\n<tool_response>\n' + content }} - {%- if ns2.consecutive_failures >= 2 %} - {{- '\n\n⚠️ SYSTEM WARNING: ' ~ ns2.consecutive_failures ~ ' consecutive tool errors detected. Your previous approach is incorrect. You MUST use a fundamentally different approach or corrected arguments.' }} - {%- elif ns2.consecutive_failures == 1 %} - {{- '\n\n⚠️ SYSTEM WARNING: The previous tool call returned an error. Diagnose the failure and retry with completely corrected arguments.' }} - {%- endif %} - {{- '\n</tool_response>' }} - {%- if loop.last %} - {{- '<|im_end|>\n' }} - {%- else %} - {%- set _next_role = _msgs[loop.index0 + 1].role %} - {%- if _next_role != 'tool' %} - {{- '<|im_end|>\n' }} - {%- endif %} - {%- endif %} - {%- else %} - {{- '<|im_start|>user\n[' + message.role + ']: ' + content + '<|im_end|>\n' }} - {%- endif %} - {%- set ns2.prev_role = message.role %} -{%- endfor %} -{%- if add_generation_prompt %} - {{- '<|im_start|>assistant\n' }} - {%- if not ns_state.thinking %} - {{- '<think>\n\n</think>\n\n' }} - {%- else %} - {{- '<think>\n' }} - {%- endif %} -{%- endif %} \ No newline at end of file diff --git a/scripts/.AGENTTRASH/download-q8agent.sh.rocmfpx b/scripts/.AGENTTRASH/download-q8agent.sh.rocmfpx deleted file mode 100755 index d27d6f7..0000000 --- a/scripts/.AGENTTRASH/download-q8agent.sh.rocmfpx +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env bash -# Download the kingjones Qwen3.8-27B-ROCmFPX-Q8_0-AGENT payload for -# frank-medium from HuggingFace into -# /home/dank/models/kingjones-Qwen3.8-27B-ROCMFPX-Q8_0-AGENT. -# -# Run on Dankserver (the box that serves it). The repo is public, so the -# download works anonymously; a token in ~/.cache/huggingface/token is used -# when present, which keeps the ~28 GB main file clear of the anonymous -# rate limit at full speed. -# -# bash scripts/download-q8agent.sh -# bash scripts/download-q8agent.sh --verify-only -# -# The script is resumable (curl -C -) and verifies every artifact's size -# afterwards, so a truncated file fails loudly instead of load-crashing. -set -euo pipefail - -REPO="kingjones777/Qwen3.8-27B-ROCmFPX-Q8_0-AGENT-GGUF" -DIR="/home/dank/models/kingjones-Qwen3.8-27B-ROCMFPX-Q8_0-AGENT" -EXPECTED=( - "Qwen3.8-27B-Q8_0_ROCMFPX_AGENT.gguf:28214425952" - "mmproj-Qwen3.8-27B-BF16.gguf:931145888" - "mtp-Qwen3.8-27B-Q4_0.gguf:1680271648" -) -# The model card's MTP prompt-cache fix ships in the same repo as a patch; -# fetched for reference only. This fork's main already carries the -# speculative boundary in the context checkpoint, so nothing applies it. -EXTRA=( - "patches/mtp-prompt-cache-fix.patch" -) - -cd -- "$(dirname -- "$0")/.." - -main() { - local mode="download" - [[ "${1:-}" == "--verify-only" ]] && mode="verify" - mkdir -p "$DIR" - cd "$DIR" - - local token="" auth=() - if [[ -r "$HOME/.cache/huggingface/token" ]]; then - token="$(cat "$HOME/.cache/huggingface/token")" - auth=(-H "Authorization: Bearer $token") - else - echo "no HF token at $HOME/.cache/huggingface/token; downloading anonymously" - fi - - dl() { - local f="$1" - mkdir -p "$(dirname "$f")" - curl -fsSL -C - -o "$f" --retry 10 --retry-delay 5 --retry-max-time 10800 \ - "${auth[@]}" \ - "https://huggingface.co/$REPO/resolve/main/$f" - echo " done: $f ($(du -h "$f" | cut -f1))" - } - - if [[ "$mode" == "download" ]]; then - echo "downloading $REPO into $DIR" - for entry in "${EXPECTED[@]}"; do - dl "${entry%%:*}" - done - for f in "${EXTRA[@]}"; do - dl "$f" - done - fi - - echo "verifying sizes" - local ok=1 f want act - for entry in "${EXPECTED[@]}"; do - f="${entry%%:*}"; want="${entry##*:}" - act="$(stat -c%s "$f" 2>/dev/null || echo 0)" - if [[ "$act" -eq "$want" ]]; then - echo " OK $f ($act bytes)" - else - echo " BAD $f ($act bytes, want $want)" >&2 - ok=0 - fi - done - if [[ -z "$token" ]]; then - echo "note: no token was used; sizes above are the integrity check" - fi - [[ "$ok" -eq 1 ]] || exit 1 - echo "all files present and sized correctly" -} - -main "$@" diff --git a/scripts/.AGENTTRASH/frank-default-npu.env b/scripts/.AGENTTRASH/frank-default-npu.env deleted file mode 100644 index fc6662f..0000000 --- a/scripts/.AGENTTRASH/frank-default-npu.env +++ /dev/null @@ -1,17 +0,0 @@ -# Tunables for frank-default-npu (FastFlowLM NPU endpoint, Dankclaw) -# Runtime: FLM 1.0.1 on the XDNA2 NPU (50 TOPS). Model: Gemma4-E4B-IT -# (8B, Q4_1, vision + audio + reasoning). The NPU allocates from the -# unified pool shared with frank-default-large (the CIRU llama.cpp -# service on the iGPU). See serverAppConfigs-docs/FastFlowLM NPU Inference.md. -# -# 2026-08-20: gemma4-it:e4b deployed. Footprint ~9.1 GB. 64k context. - -FLM_MODEL=gemma4-it:e4b -FLM_HOST=0.0.0.0 -FLM_PORT=52626 -FLM_PMODE=performance -FLM_ASR=0 -# 64K context (native max for gemma4-it is 32768, but NPU supports up to 256k). -# The model's max_prefill_len is 4096, so prompts longer than that get -# truncated at prefill time. -FLM_CTX_LEN=65536 diff --git a/scripts/.AGENTTRASH/frank-default-npu.service b/scripts/.AGENTTRASH/frank-default-npu.service deleted file mode 100644 index 14a7af5..0000000 --- a/scripts/.AGENTTRASH/frank-default-npu.service +++ /dev/null @@ -1,17 +0,0 @@ -[Unit] -Description=FastFlowLM NPU inference (frank-default-npu) on Dankclaw -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -User=dank -Group=dank -Environment="FLM_NPU_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-default-npu.env" -ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-default-npu.sh -LimitMEMLOCK=infinity -Restart=on-failure -RestartSec=10 - -[Install] -WantedBy=multi-user.target diff --git a/scripts/.AGENTTRASH/frank-dragoon.env.ik-llama.cpp b/scripts/.AGENTTRASH/frank-dragoon.env.ik-llama.cpp deleted file mode 100644 index 8a67d46..0000000 --- a/scripts/.AGENTTRASH/frank-dragoon.env.ik-llama.cpp +++ /dev/null @@ -1,105 +0,0 @@ -# Tunables for frank-dragoon (mradermacher i1-Q4_K_M of -# orcarouter/Qwen3.8-Flash-Next-Uncensored on ik_llama.cpp, Dankclaw, -# RTX 3090 CUDA + Strix Halo CPU path, since 2026-09-15). -# -# Runtime: ik_llama.cpp master d85a1d7 (2026-09-15) with full qwen4exp -# support (upstream PRs #2365/#2369/#2373 merged). Build recipe (CUDA for -# the 3090 at sm_86, Zen5 AVX-512 IQK kernels for the CPU expert path): -# cmake -B build -DCMAKE_BUILD_TYPE=Release -DGGML_NATIVE=ON \ -# -DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=86 \ -# -DGGML_AVX512=ON -DGGML_AVX512_VBMI=ON -DGGML_AVX512_VNNI=ON \ -# -DGGML_AVX512_BF16=ON -DLLAMA_CURL=OFF -# The startup banner must print HAVE_FANCY_SIMD is defined — without those -# macros the quantized GEMM silently falls back to AVX2 (no warning). -# -# Model: mradermacher imatrix quant of the uncensored Flash-Next (qwen4exp -# MoE: 512 experts / 10 used, 48 layers = 12 attention + 36 GDN/ssm, QSA -# indexer, hyper-connections, PLE n-gram table ~320M rows). 111 GiB shipped -# as 3 parts, concatenated on disk; each part's sha256 was verified against -# its HF LFS oid at download time. -# -# Placement (ikawrakow's hybrid recipe; upstream issue #2402 for the PLE): -# - -ot "exps=CPU": ALL 512-expert tensors (121B elems, ~84 GB) live in -# the Strix Halo's LPDDR5X via the plain CPU backend buffer — mmap-backed -# page cache, ik's fastest known configuration for this shape (the -# author's 2x3090 + Threadripper reference box measures ~50 t/s on the -# same model family). The shared experts (shexp) keep their GPU-side -# default: they run on every token. -# - -ot "per_layer_token_embd=CPU": the ~32 GB PLE n-gram table follows — -# only a tiny slice is touched per token, so it streams from page cache. -# - NEVER use --cpu-moe on this host: it routes the CPU-overridden -# weights through the CUDA_Host PINNED buffer (measured 2026-09-15: -# 108.12 GiB page-locked, evicted every co-tenant into swap and starved -# sshd into unreachability until a power cycle). -ot ...=CPU is the -# mmap-backed path with identical DDR5 bandwidth for resident pages, -# and the kernel degrades gracefully under pressure (page cache evict -# + re-read) instead of stranding anyone. -# - GGML_CUDA_NO_PINNED_WEIGHTS=1 (src/llama-load-tensors.cpp) keeps the -# CPU-overridden weights mmapped and leaves only the small staging -# buffers pinned. (GGML_CUDA_NO_PINNED would also unpin staging — -# unnecessary here and slower for prefill transfers.) -# -# Sampling: greedy-by-default to preserve the halogen-era client contract -# (a request without temperature decodes greedy; temp 0.0 = argmax). -# Clients that send their own temperature get their own values. -# -# WARNING: do not name any variable TEMP, HOST, or GPU_MAX_HEAP_SIZE (see -# the 2026-08-13 HIP incident in AGENTS.md). llama.cpp reads --host and -# --temp as CLI flags, never as environment variables. - -# ik_llama.cpp runtime binary (master d85a1d7 build). -LLAMA_BIN=/home/dank/GitPackages/ik_llama.cpp/build/bin/llama-server - -# Model package from -# https://huggingface.co/mradermacher/Qwen3.8-Flash-Next-Uncensored-i1-GGUF -# (parts concatenated). The mmproj is the matching vision tower from the -# static repo mradermacher/Qwen3.8-Flash-Next-Uncensored-GGUF — ik_llama.cpp -# links examples/mtmd, so image input survives the runtime swap. -MODEL_DIR=/home/dank/models/mradermacher-Qwen3.8-Flash-Next-Uncensored-i1-Q4_K_M -MODEL_FILE=Qwen3.8-Flash-Next-Uncensored.i1-Q4_K_M.gguf -MMPROJ_PATH=/home/dank/models/mradermacher-Qwen3.8-Flash-Next-Uncensored-i1-Q4_K_M/Qwen3.8-Flash-Next-Uncensored.mmproj-f16.gguf - -# Qwen-Sharp chat template (tracked in the repo, staged on the host). The -# server emits reasoning in DeepSeek format so clients receive -# reasoning_content separately instead of literal think tags in content. -CHAT_TEMPLATE_FILE=/home/dank/serverAppConfigs/scripts/chat-template.jinja - -# Alias + network. -ALIAS=frank-dragoon -BIND_ADDR=0.0.0.0 -LISTEN_PORT=7770 - -# Serving: single 262144-token slot (the model's full context window), -# batch 4096 / ubatch 512, 16+16 threads (the Halo CPU has 16 cores, no -# SMT), f16 KV. The KV cache scales with the 12 full-attention layers only -# (~6 GB f16 at 262k); the GDN/ssm layers keep constant-size state. If -# VRAM headroom is needed, --cache-type-k/v q8_0 halves the KV footprint. -CTX_SIZE=262144 -PARALLEL=1 -BATCH_SIZE=4096 -UBATCH_SIZE=512 -NUM_THREADS=16 -THREAD_BATCH=16 -CACHE_TYPE_K=f16 -CACHE_TYPE_V=f16 - -# GPU/MoE placement: everything on the 3090 except the experts and the PLE -# table, which both live in host RAM via the CPU backend buffer (see the -# placement notes above). -NGL=999 -# ik_llama.cpp CUDA host-memory policy: CPU-overridden weights stay mmapped; -# only the small staging buffers are pinned (see the placement notes above). -GGML_CUDA_NO_PINNED_WEIGHTS=1 - -# Prompt cache (RAM budget in MiB) and slot state dir. -CACHE_RAM=8192 -SLOT_SAVE_PATH=/home/dank/serverAppConfigs/scripts/slot-state - -# Greedy-by-default (halogen contract). See sampling note above. -TEMPERATURE=0.0 - -# Reasoning on: DeepSeek format projects thinking into the API's -# reasoning_content field instead of literal think tags in content; -# unlimited reasoning budget (the model decides). -REASONING_FORMAT=deepseek -REASONING_BUDGET=-1 diff --git a/scripts/.AGENTTRASH/frank-dragoon.service.ik-llama.cpp b/scripts/.AGENTTRASH/frank-dragoon.service.ik-llama.cpp deleted file mode 100644 index eae0d10..0000000 --- a/scripts/.AGENTTRASH/frank-dragoon.service.ik-llama.cpp +++ /dev/null @@ -1,21 +0,0 @@ -[Unit] -Description=ik_llama.cpp Qwen3.8-Flash-Next-Uncensored i1-Q4_K_M 177B MoE (dankclaw, frank-dragoon) -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -User=dank -Group=dank -# Staging buffers for the CUDA transfers are page-locked; the weights -# themselves stay mmapped (GGML_CUDA_NO_PINNED_WEIGHTS=1), but the staging -# budget still needs room. Parity with the halogen quadlet's -# Ulimit=memlock=-1:-1. -LimitMEMLOCK=infinity -Environment="FRANK_DRAGOON_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-dragoon.env" -ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-dragoon.sh -Restart=on-failure -RestartSec=10 - -[Install] -WantedBy=multi-user.target diff --git a/scripts/.AGENTTRASH/frank-medium.env.rocmfpx b/scripts/.AGENTTRASH/frank-medium.env.rocmfpx deleted file mode 100644 index a9fdd09..0000000 --- a/scripts/.AGENTTRASH/frank-medium.env.rocmfpx +++ /dev/null @@ -1,138 +0,0 @@ -# Tunables for frank-medium (kingjones777/Qwen3.8-27B-ROCmFPX-Q8_0-AGENT-GGUF, -# charlie12345/ROCmFPX llama.cpp, Dankserver, 2x AMD Radeon AI PRO R9700). -# -# Hardware (2026-09-15): the RTX 3090 left Dankserver and two Radeon AI PRO -# R9700 cards (Navi 48, gfx1201, RDNA4, 32,624 MiB each, PCIe 5.0 x16) took -# its place. The Raphael iGPU (gfx1036) is still enumerated by HIP as the -# third device. There is no XGMI link between the two cards and they sit in -# different IOMMU groups, so any cross-GPU traffic goes over PCIe. -# -# Runtime: charlie12345/ROCmFPX (llama.cpp fork, ROCmFPX quant family; the -# Q8_0_ROCMFPX_AGENT tensor type exists nowhere else - stock llama.cpp cannot -# load this GGUF). Built for gfx1201 with the fork's self-contained ROCm -# 7.14 toolchain so the host's stale /opt/rocm-6.3.3 install is never used: -# -# cd /home/dank/GitPackages/charlie-ROCmFPX -# env JOBS=8 ./scripts/build-rocmfp4-rocm714-local.sh -# -# gfx1201 must be the build target: a gfx1200 binary links, loads the model -# and then segfaults on this card (fork's docs/BUILD-AMD-ARCHITECTURES.md). -# -# Model: kingjones Qwen3.8-27B-ROCmFPX-Q8_0-AGENT - Q8_0_ROCMFPX_AGENT -# (ftype 115), 26.28 GiB, 8.39 bpw, arch qwen35 (MoE), quantized from a -# 51.3 GiB BF16 source. The AGENT routing keeps 12 more attention-output -# projections at full Q8_0 than the plain 8-bit build, which buys a higher -# MTP draft acceptance (0.953 vs 0.911) and that is the whole point of this -# checkpoint. Model card documents 131,072 context. -# -# Parallelism (--split-mode layer, operator call 2026-09-15 "pipeline -# parallelism"): layers are distributed across the two cards in order and -# activations hop PCIe at the boundary. Measured with llama-bench -# (pp4096 / tg128 / same at depth 32768, f16 KV, fa on, 3 reps): -# -# single R9700 1160.9 / 19.42 (fits: 26.27 GiB weights, ~5 GiB left) -# layer 2x 1991.2 / 18.76 859.1 / 17.64 -# tensor 2x 271.8 / 26.26 228.6 / 25.09 -# -# Tensor parallel wins decode (+40 %) but collapses prefill 7x: this build -# has no RCCL (llama.cpp logs "AllReduce init failed; falling back to -# meta-backend butterfly"), so every layer pays a slow cross-card reduction. -# Agentic traffic is prefill-heavy, so layer split wins the workload. Revisit -# if the fork ships a working RCCL tensor path. -# -# WARNING: do not name any variable TEMP, HOST, or GPU_MAX_HEAP_SIZE. The -# ROCm HIP runtime reads those bare env vars (see the 2026-08-13 TEMP -# incident). llama.cpp reads --host/--port/--temp as CLI flags, never as -# env vars. - -# charlie12345/ROCmFPX llama-server (self-contained ROCm 7.14 build, RPATH -# points at the bundled runtime libs; no LD_LIBRARY_PATH needed). -LLAMA_BIN=/home/dank/GitPackages/charlie-ROCmFPX/build-rdna4-rocm714/bin/llama-server - -# Model package, fetched with scripts/download-q8agent.sh: -# Qwen3.8-27B-Q8_0_ROCMFPX_AGENT.gguf 26.28 GiB main weights -# mtp-Qwen3.8-27B-Q4_0.gguf 1.68 GiB MTP draft head -# mmproj-Qwen3.8-27B-BF16.gguf 0.93 GiB vision tower (unused) -MODEL_DIR=/home/dank/models/kingjones-Qwen3.8-27B-ROCMFPX-Q8_0-AGENT -MODEL_FILE=Qwen3.8-27B-Q8_0_ROCMFPX_AGENT.gguf -DRAFT_MODEL_FILE=mtp-Qwen3.8-27B-Q4_0.gguf - -# Alias + network. The port is the fleet's dankserver endpoint; the alias is -# what clients ask for, and it replaces the retired `frank-small`. -ALIAS=frank-medium -BIND_ADDR=0.0.0.0 -LISTEN_PORT=7770 - -# Device placement. HIP_VISIBLE_DEVICES hides the iGPU (device 2) so nothing -# can drift onto shared system RAM; DEVICES is what llama.cpp splits across. -HIP_VISIBLE_DEVICES=0,1 -DEVICES=ROCm0,ROCm1 -SPLIT_MODE=layer - -# TENSOR_SPLIT empty = layers distributed by free VRAM (equal here). -TENSOR_SPLIT= - -NGL=999 -# Auto-fit has no business resizing a context we set explicitly. -FIT=off -FLASH_ATTN=on - -# 131,072 tokens, one slot (the model card's documented window; the GGUF -# reports n_ctx_train 262144, untested by the model author). Layer split -# keeps each layer's KV on the card that owns the layer, so the window is -# bounded by per-card free memory: ~5.6 GiB of KV per card at 131072 f16. -CTX_SIZE=131072 -PARALLEL=1 - -# 26.28 GiB of weights + MTP draft + KV cache leaves ~15 GiB per card at this -# context, so the batch shape is chosen for prefill throughput, not memory. -BATCH_SIZE=2048 -UBATCH_SIZE=512 -NUM_THREADS=8 -THREAD_BATCH=8 - -# f16 K/V: an agent checkpoint is the wrong place to spend KV precision. -CACHE_TYPE_K=f16 -CACHE_TYPE_V=f16 -CTX_CHECKPOINTS=8 -CACHE_RAM=8192 - -# Sampling: the checkpoint's instruct table. -TEMPERATURE=0.7 -TOP_P=0.8 -TOP_K=20 -MIN_P=0.0 -PRESENCE_PENALTY=1.5 - -# Reasoning on; DeepSeek format projects the trace into reasoning_content -# instead of literal thinking tags in content. -REASONING=on -REASONING_FORMAT=deepseek -REASONING_BUDGET=-1 - -# Chat template: empty = the template embedded in the GGUF. Point this at -# /home/dank/serverAppConfigs/scripts/chat-template.jinja to force the repo's -# Sharp agent template instead. -CHAT_TEMPLATE_FILE= -CHAT_TEMPLATE_KWARGS='{"enable_thinking":true,"preserve_thinking":true}' - -# MTP speculative decoding. n_max 6 / p_min 0.6 is the fork's measured -# sweet spot for a MTP head with high acceptance; p_min 0.0 drafts -# low-probability tokens that get rejected and makes MTP a net loss. -SPEC_TYPE=draft-mtp -SPEC_DRAFT_N_MAX=6 -SPEC_DRAFT_N_MIN=0 -SPEC_DRAFT_P_MIN=0.6 -SPEC_DRAFT_P_SPLIT=0.10 -SPEC_DRAFT_NGL=999 -SPEC_DRAFT_TYPE_K=f16 -SPEC_DRAFT_TYPE_V=f16 -SPEC_DRAFT_POLL=1 -SPEC_DRAFT_POLL_BATCH=1 -# The draft head rides the card that holds the target model's LAST layers: -# the MTP head consumes the final hidden state, so pinning it to ROCm1 keeps -# the per-token hop off the wire. (Under the abandoned tensor split the -# draft must also be pinned to exactly one device or the meta-device KV -# allocation aborts: GGML_ASSERT(buffer) in ggml-backend.cpp, measured -# 2026-09-15.) -SPEC_DRAFT_DEVICE=ROCm1 diff --git a/scripts/.AGENTTRASH/frank-medium.service.rocmfpx b/scripts/.AGENTTRASH/frank-medium.service.rocmfpx deleted file mode 100644 index 35ce5ae..0000000 --- a/scripts/.AGENTTRASH/frank-medium.service.rocmfpx +++ /dev/null @@ -1,16 +0,0 @@ -[Unit] -Description=charlie12345/ROCmFPX llama.cpp serving kingjones777/Qwen3.8-27B-ROCmFPX-Q8_0-AGENT qwen35 MoE (dankserver, frank-medium, 2x Radeon AI PRO R9700) -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -User=dank -Group=dank -Environment="FRANK_MEDIUM_ENV_FILE=/home/dank/serverAppConfigs/scripts/frank-medium.env" -ExecStart=/home/dank/serverAppConfigs/scripts/serve-frank-medium.sh -Restart=on-failure -RestartSec=10 - -[Install] -WantedBy=multi-user.target diff --git a/scripts/.AGENTTRASH/serve-frank-default-npu.sh b/scripts/.AGENTTRASH/serve-frank-default-npu.sh deleted file mode 100755 index 17c2168..0000000 --- a/scripts/.AGENTTRASH/serve-frank-default-npu.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env bash -# Serve the FastFlowLM (FLM) NPU endpoint — frank-default-npu. -# -# Runs flm serve with the model tag in frank-default-npu.env. The NPU -# (XDNA2, 50 TOPS) is a single-lane device: generations serialize in FIFO -# order, so this is a background/triage endpoint. The NPU shares the ~30 GiB -# unified pool with frank-default-large (the CIRU llama.cpp service on the -# iGPU); see serverAppConfigs-docs/FastFlowLM NPU Inference.md for the memory budget. -# -# NOTE: FLM requires unlimited memlock. The unit sets LimitMEMLOCK=infinity. -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ENV_FILE="${FLM_NPU_ENV_FILE:-$SCRIPT_DIR/frank-default-npu.env}" -if [[ -f "$ENV_FILE" ]]; then - set -a - # shellcheck disable=SC1090 - source "$ENV_FILE" - set +a -fi - -# --- defaults (used when the env file does not set them) ------------------- -: "${FLM_BIN:=/usr/bin/flm}" -: "${FLM_MODEL:=gemma4-it:e4b}" -: "${FLM_HOST:=0.0.0.0}" -: "${FLM_PORT:=52626}" -: "${FLM_PMODE:=performance}" -: "${FLM_ASR:=0}" -: "${FLM_CTX_LEN:=-1}" - -if [[ ! -x "$FLM_BIN" ]]; then - echo "missing flm binary: $FLM_BIN" >&2 - exit 1 -fi - -# --- optional flags ---------------------------------------------------------- -OPT_ARGS=() -if [[ "${FLM_ASR}" != "0" ]]; then - OPT_ARGS+=(-a "${FLM_ASR}") -fi -if [[ "${FLM_CTX_LEN}" != "-1" ]]; then - OPT_ARGS+=(-c "${FLM_CTX_LEN}") -fi - -exec "$FLM_BIN" serve "$FLM_MODEL" \ - --host "$FLM_HOST" \ - -p "$FLM_PORT" \ - --pmode "$FLM_PMODE" \ - "${OPT_ARGS[@]}" diff --git a/scripts/.AGENTTRASH/serve-frank-dragoon.sh.ik-llama.cpp b/scripts/.AGENTTRASH/serve-frank-dragoon.sh.ik-llama.cpp deleted file mode 100755 index e6e09c7..0000000 --- a/scripts/.AGENTTRASH/serve-frank-dragoon.sh.ik-llama.cpp +++ /dev/null @@ -1,123 +0,0 @@ -#!/usr/bin/env bash -# Serve mradermacher/Qwen3.8-Flash-Next-Uncensored i1-Q4_K_M as frank-dragoon -# via ik_llama.cpp on Dankclaw. -# -# Topology (ikawrakow's hybrid recipe; upstream issue #2402 for the PLE): -# the RTX 3090 (CUDA, sm_86) takes the dense/attention/output weights and -# the KV cache (-ngl 999); ALL MoE expert tensors and the ~32 GB PLE -# n-gram table stay in the Strix Halo's LPDDR5X via the plain CPU backend -# buffer (-ot ...=CPU — mmap-backed page cache; only a tiny slice of PLE is -# touched per token). NEVER use --cpu-moe on this host: it routes the -# CPU-overridden weights through the CUDA_Host PINNED buffer (measured -# 108.12 GiB page-locked on 2026-09-15, which evicted every co-tenant into -# swap and starved sshd into unreachability until a power cycle). NEVER add -# --no-mmap — the STRIX-era lesson transfers directly (the cgroup killer -# takes the process with nothing in the server log). -# -# Runtime: ik_llama.cpp master d85a1d7 built with CUDA (sm_86) + Zen5 -# AVX-512 IQK kernels; the startup banner must show HAVE_FANCY_SIMD -# is defined. Flag set verified against this binary's --help before this -# script was written. ik has no --cache-prompt/--cache-idle-slots (prompt -# caching is its default behavior) and no --alias: it is a single-model -# server and reports the GGUF's general.name at /v1/models. -# -# Sampling: greedy-by-default (halogen client contract) — clients that -# send their own temperature get their own values. -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -ENV_FILE="${FRANK_DRAGOON_ENV_FILE:-$SCRIPT_DIR/frank-dragoon.env}" -if [[ -f "$ENV_FILE" ]]; then - set -a - # shellcheck disable=SC1090 - source "$ENV_FILE" - set +a -fi - -# --- defaults (used when the env file does not set them) ------------------- -: "${LLAMA_BIN:=/home/dank/GitPackages/ik_llama.cpp/build/bin/llama-server}" -: "${MODEL_DIR:=/home/dank/models/mradermacher-Qwen3.8-Flash-Next-Uncensored-i1-Q4_K_M}" -: "${MODEL_FILE:=Qwen3.8-Flash-Next-Uncensored.i1-Q4_K_M.gguf}" -: "${MMPROJ_PATH:=$MODEL_DIR/Qwen3.8-Flash-Next-Uncensored.mmproj-f16.gguf}" -: "${CHAT_TEMPLATE_FILE:=$SCRIPT_DIR/chat-template.jinja}" -: "${BIND_ADDR:=0.0.0.0}" -: "${LISTEN_PORT:=7770}" -: "${CTX_SIZE:=262144}" -: "${PARALLEL:=1}" -: "${BATCH_SIZE:=4096}" -: "${UBATCH_SIZE:=512}" -: "${NUM_THREADS:=16}" -: "${THREAD_BATCH:=16}" -: "${CACHE_TYPE_K:=f16}" -: "${CACHE_TYPE_V:=f16}" -: "${NGL:=999}" -: "${CPU_MOE:=on}" -: "${FLASH_ATTN:=on}" -: "${TEMPERATURE:=0.0}" -: "${REASONING:=on}" -: "${REASONING_FORMAT:=deepseek}" -: "${REASONING_BUDGET:=-1}" -: "${CACHE_RAM:=8192}" -: "${SLOT_SAVE_PATH:=$SCRIPT_DIR/slot-state}" -# Hard guard: on this host CUDA pinned host memory strands the co-tenants -# (measured 2026-09-15: a default-policy start tried to page-lock 108.12 GiB, -# evicted every co-tenant into swap and starved sshd into unreachability -# until a power cycle). GGML_CUDA_NO_PINNED_WEIGHTS=1 keeps the -# CPU-overridden weights mmapped (src/llama-load-tensors.cpp); the env file -# sets it. Refuse to run without it. -if [[ "${GGML_CUDA_NO_PINNED_WEIGHTS:-}" != "1" ]]; then - echo "frank-dragoon: refusing to start — GGML_CUDA_NO_PINNED_WEIGHTS=1 is mandatory on this host" >&2 - exit 1 -fi - -mkdir -p "$SLOT_SAVE_PATH" - -# The UVM device node is created lazily by nvidia-modprobe on the first -# CUDA open after boot; make sure it exists before the server needs it. -nvidia-modprobe -u -c=0 2>/dev/null || true - -CPU_MOE_ARGS=() -if [[ "$CPU_MOE" == "on" ]]; then - # -ot ...=CPU (plain CPU backend buffer) is the mmap-backed path. NEVER - # swap this for --cpu-moe: that routes the weights through the CUDA_Host - # PINNED buffer (108.12 GiB measured) and strands the co-tenants. - CPU_MOE_ARGS=(-ot "exps=CPU" -ot "per_layer_token_embd=CPU") -fi - -MMPROJ_ARGS=() -if [[ -n "${MMPROJ_PATH:-}" && -f "$MMPROJ_PATH" ]]; then - MMPROJ_ARGS=(--mmproj "$MMPROJ_PATH") -fi - -TEMPLATE_ARGS=() -if [[ -n "${CHAT_TEMPLATE_FILE:-}" && -f "$CHAT_TEMPLATE_FILE" ]]; then - TEMPLATE_ARGS=(--chat-template-file "$CHAT_TEMPLATE_FILE") -else - TEMPLATE_ARGS=(--jinja) -fi - -exec "$LLAMA_BIN" \ - --model "$MODEL_DIR/$MODEL_FILE" \ - "${TEMPLATE_ARGS[@]}" \ - --host "$BIND_ADDR" \ - --port "$LISTEN_PORT" \ - --gpu-layers "$NGL" \ - "${CPU_MOE_ARGS[@]}" \ - --flash-attn "$FLASH_ATTN" \ - --parallel "$PARALLEL" \ - --ctx-size "$CTX_SIZE" \ - --batch-size "$BATCH_SIZE" \ - --ubatch-size "$UBATCH_SIZE" \ - --threads "$NUM_THREADS" \ - --threads-batch "$THREAD_BATCH" \ - --cache-type-k "$CACHE_TYPE_K" \ - --cache-type-v "$CACHE_TYPE_V" \ - --cache-ram "$CACHE_RAM" \ - --slot-save-path "$SLOT_SAVE_PATH" \ - --ctx-checkpoints 16 \ - --metrics \ - --temp "$TEMPERATURE" \ - --reasoning "$REASONING" \ - --reasoning-format "$REASONING_FORMAT" \ - --reasoning-budget "$REASONING_BUDGET" \ - "${MMPROJ_ARGS[@]}" diff --git a/scripts/.AGENTTRASH/serve-frank-medium.sh.rocmfpx b/scripts/.AGENTTRASH/serve-frank-medium.sh.rocmfpx deleted file mode 100755 index 8b64445..0000000 --- a/scripts/.AGENTTRASH/serve-frank-medium.sh.rocmfpx +++ /dev/null @@ -1,215 +0,0 @@ -#!/usr/bin/env bash -# Serve kingjones777/Qwen3.8-27B-ROCmFPX-Q8_0-AGENT-GGUF as frank-medium via -# the charlie12345/ROCmFPX llama.cpp runtime on Dankserver -# (2x AMD Radeon AI PRO R9700, Navi 48, gfx1201, 32 GB each). -# -# Runtime: charlie12345/ROCmFPX main (c49ebdb, 2026-08-22), built for -# gfx1201 with the fork's self-contained ROCm toolchain: -# -# cd /home/dank/GitPackages/charlie-ROCmFPX -# env JOBS=8 ./scripts/build-rocmfp4-rocm714-local.sh -# -> build-rdna4-rocm714/bin/llama-server -# -# That script downloads the gfx120X-all TheRock ROCm 7.14 toolchain into -# ~/.local/share/rocm-7.14.0a20260624, builds with -# -DCMAKE_HIP_ARCHITECTURES="gfx1200;gfx1201", copies the ROCm runtime -# (hip/hsa/rocblas/hipblaslt + their kernel libraries) next to the binary and -# sets RPATH=$ORIGIN/../lib on it. The resulting service is self-contained: -# no LD_LIBRARY_PATH, and the host's stale /opt/rocm-6.3.3 cannot leak in. -# gfx1201 is NOT interchangeable with gfx1200 on this card (the fork's -# docs/BUILD-AMD-ARCHITECTURES.md: a gfx1200 build links, loads and then -# segfaults), and the build verifies the emitted code objects afterwards. -# -# Model: kingjones Qwen3.8-27B-ROCmFPX-Q8_0-AGENT (Q8_0_ROCMFPX_AGENT, -# ftype 115, arch qwen35 MoE), 26.28 GiB main file, plus a separate -# MTP draft head and an mmproj vision tower. 8.39 bpw; the AGENT routing -# keeps 13 of the 17 attention-output projections at full Q8_0, which is -# what makes the MTP draft land ~0.95 of its guesses. A stock llama.cpp -# cannot load this GGUF at all - Q8_0_ROCMFPX exists only in this fork. -# -# Parallelism: --split-mode layer across both cards (operator call -# 2026-09-15). Layers are distributed in order and activations hop PCIe at -# the boundary; measured llama-bench: layer 1991/18.8 tok/s (pp4096/tg128) -# vs tensor 272/26.3 and single-card 1161/19.4. Tensor wins decode +40 % -# but its prefill collapses 7x on this build (no RCCL: "AllReduce init -# failed; falling back to meta-backend butterfly"), so layer split wins the -# prefill-heavy agentic workload. -# -# Tunables come from frank-medium.env unless FRANK_MEDIUM_ENV_FILE -# overrides that path. -set -euo pipefail - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" -ENV_FILE="${FRANK_MEDIUM_ENV_FILE:-$SCRIPT_DIR/frank-medium.env}" -if [[ -f "$ENV_FILE" ]]; then - set -a - # shellcheck disable=SC1090 - source "$ENV_FILE" - set +a -fi - -# --- defaults (used when the env file does not set them) ----------------- -: "${LLAMA_BIN:=/home/dank/GitPackages/charlie-ROCmFPX/build-rdna4-rocm714/bin/llama-server}" -: "${MODEL_DIR:=/home/dank/models/kingjones-Qwen3.8-27B-ROCMFPX-Q8_0-AGENT}" -: "${MODEL_FILE:=Qwen3.8-27B-Q8_0_ROCMFPX_AGENT.gguf}" -: "${DRAFT_MODEL_FILE:=mtp-Qwen3.8-27B-Q4_0.gguf}" -: "${ALIAS:=frank-medium}" -: "${BIND_ADDR:=0.0.0.0}" -: "${LISTEN_PORT:=7770}" - -# Devices: ROCm0/ROCm1 are the two R9700s; the Raphael iGPU enumerates as -# ROCm2 (gfx1036) and is hidden from HIP so nothing can drift onto it. -: "${HIP_VISIBLE_DEVICES:=0,1}" -: "${DEVICES:=ROCm0,ROCm1}" -: "${SPLIT_MODE:=tensor}" -: "${TENSOR_SPLIT:=}" -: "${NGL:=999}" -: "${FIT:=off}" -: "${FLASH_ATTN:=on}" - -: "${CTX_SIZE:=131072}" -: "${PARALLEL:=1}" -: "${BATCH_SIZE:=2048}" -: "${UBATCH_SIZE:=512}" -: "${NUM_THREADS:=8}" -: "${THREAD_BATCH:=8}" -: "${CACHE_TYPE_K:=f16}" -: "${CACHE_TYPE_V:=f16}" -: "${CTX_CHECKPOINTS:=8}" -: "${CACHE_RAM:=8192}" - -# Sampling: the checkpoint's instruct table. -: "${TEMPERATURE:=0.7}" -: "${TOP_P:=0.8}" -: "${TOP_K:=20}" -: "${MIN_P:=0.0}" -: "${PRESENCE_PENALTY:=1.5}" - -# Reasoning: the model is an agent checkpoint; keep the trace and project it -# into the API's reasoning_content field rather than literal thinking tags. -: "${REASONING:=on}" -: "${REASONING_FORMAT:=deepseek}" -: "${REASONING_BUDGET:=-1}" - -# Chat template: empty means "use the template embedded in the GGUF". -: "${CHAT_TEMPLATE_FILE:=}" -: "${CHAT_TEMPLATE_KWARGS:={\"enable_thinking\":true,\"preserve_thinking\":true}}" - -# MTP draft head (separate GGUF, --spec-type draft-mtp). SPEC_DRAFT_DEVICE -# must name exactly one device: under tensor mode an unsplit draft joins the -# meta-device KV allocation and aborts while building the KV cache -# (GGML_ASSERT(buffer) in ggml-backend.cpp, 2026-09-15). -: "${SPEC_TYPE:=draft-mtp}" -: "${SPEC_DRAFT_N_MAX:=6}" -: "${SPEC_DRAFT_N_MIN:=0}" -: "${SPEC_DRAFT_P_MIN:=0.6}" -: "${SPEC_DRAFT_P_SPLIT:=0.10}" -: "${SPEC_DRAFT_NGL:=999}" -: "${SPEC_DRAFT_DEVICE:=}" -: "${SPEC_DRAFT_TYPE_K:=f16}" -: "${SPEC_DRAFT_TYPE_V:=f16}" -: "${SPEC_DRAFT_POLL:=1}" -: "${SPEC_DRAFT_POLL_BATCH:=1}" - -# Text only: the server must not try to autoload the vision tower. Vision -# stays on frank-dragoon (Dankclaw), which is where the fleet routes images. -NO_MMPROJ="${NO_MMPROJ:-1}" - -export HIP_VISIBLE_DEVICES - -MODEL="$MODEL_DIR/$MODEL_FILE" -if [[ ! -x "$LLAMA_BIN" ]]; then - echo "missing executable runtime: $LLAMA_BIN" >&2 - echo "build it with: cd $(dirname "$(dirname "$LLAMA_BIN")")/.. && env JOBS=8 ./scripts/build-rocmfp4-rocm714-local.sh" >&2 - exit 1 -fi -if [[ ! -r "$MODEL" ]]; then - echo "missing or unreadable required artifact: $MODEL" >&2 - echo "fetch it with: bash $SCRIPT_DIR/download-q8agent.sh" >&2 - exit 1 -fi -if [[ -n "$CHAT_TEMPLATE_FILE" && ! -r "$CHAT_TEMPLATE_FILE" ]]; then - echo "missing or unreadable chat template: $CHAT_TEMPLATE_FILE" >&2 - exit 1 -fi - -# --- speculative decoding (MTP draft head) -------------------------------- -SPEC_ARGS=() -if [[ -n "$SPEC_TYPE" && "$SPEC_TYPE" != "none" ]]; then - SPEC_ARGS=( - --spec-type "$SPEC_TYPE" - --spec-draft-n-max "$SPEC_DRAFT_N_MAX" - --spec-draft-n-min "$SPEC_DRAFT_N_MIN" - --spec-draft-p-min "$SPEC_DRAFT_P_MIN" - --spec-draft-p-split "$SPEC_DRAFT_P_SPLIT" - ) - if [[ "$SPEC_TYPE" == *draft-mtp* ]]; then - DRAFT_MODEL="$MODEL_DIR/$DRAFT_MODEL_FILE" - if [[ ! -r "$DRAFT_MODEL" ]]; then - echo "missing or unreadable draft model: $DRAFT_MODEL" >&2 - exit 1 - fi - SPEC_ARGS+=( - --spec-draft-model "$DRAFT_MODEL" - --spec-draft-ngl "$SPEC_DRAFT_NGL" - --spec-draft-type-k "$SPEC_DRAFT_TYPE_K" - --spec-draft-type-v "$SPEC_DRAFT_TYPE_V" - --spec-draft-poll "$SPEC_DRAFT_POLL" - --spec-draft-poll-batch "$SPEC_DRAFT_POLL_BATCH" - ) - if [[ -n "$SPEC_DRAFT_DEVICE" ]]; then - SPEC_ARGS+=(--spec-draft-device "$SPEC_DRAFT_DEVICE") - fi - fi -fi - -# --- template + placement ------------------------------------------------- -TEMPLATE_ARGS=() -if [[ -n "$CHAT_TEMPLATE_FILE" ]]; then - TEMPLATE_ARGS+=(--chat-template-file "$CHAT_TEMPLATE_FILE") - TEMPLATE_ARGS+=(--chat-template-kwargs "$CHAT_TEMPLATE_KWARGS") -fi - -PLACEMENT_ARGS=(--device "$DEVICES" --split-mode "$SPLIT_MODE") -if [[ -n "$TENSOR_SPLIT" ]]; then - PLACEMENT_ARGS+=(--tensor-split "$TENSOR_SPLIT") -fi - -MMPROJ_ARGS=() -if [[ "$NO_MMPROJ" == "1" ]]; then - MMPROJ_ARGS=(--no-mmproj) -fi - -exec "$LLAMA_BIN" \ - --model "$MODEL" \ - --alias "$ALIAS" \ - --host "$BIND_ADDR" \ - --port "$LISTEN_PORT" \ - --jinja \ - --gpu-layers "$NGL" \ - --fit "$FIT" \ - --parallel "$PARALLEL" \ - --ctx-size "$CTX_SIZE" \ - --batch-size "$BATCH_SIZE" \ - --ubatch-size "$UBATCH_SIZE" \ - --flash-attn "$FLASH_ATTN" \ - --cache-type-k "$CACHE_TYPE_K" \ - --cache-type-v "$CACHE_TYPE_V" \ - --threads "$NUM_THREADS" \ - --threads-batch "$THREAD_BATCH" \ - --ctx-checkpoints "$CTX_CHECKPOINTS" \ - --cache-ram "$CACHE_RAM" \ - --cache-prompt \ - --metrics \ - --temp "$TEMPERATURE" \ - --top-p "$TOP_P" \ - --top-k "$TOP_K" \ - --min-p "$MIN_P" \ - --presence-penalty "$PRESENCE_PENALTY" \ - --reasoning "$REASONING" \ - --reasoning-format "$REASONING_FORMAT" \ - --reasoning-budget "$REASONING_BUDGET" \ - "${TEMPLATE_ARGS[@]}" \ - "${PLACEMENT_ARGS[@]}" \ - "${MMPROJ_ARGS[@]}" \ - "${SPEC_ARGS[@]}" diff --git a/tests/.AGENTTRASH/frank_medium_proxy.bats b/tests/.AGENTTRASH/frank_medium_proxy.bats deleted file mode 100755 index f50bc36..0000000 --- a/tests/.AGENTTRASH/frank_medium_proxy.bats +++ /dev/null @@ -1,145 +0,0 @@ -#!/usr/bin/env bats -# Seam: the frank-medium proxy's routing decisions. -# The proxy is the only thing between the fleet's clients and the two -# single-GPU replicas, and its routing rules carry the performance work: spread -# single-turn requests (batch members, one-shot questions) so both cards prefill -# in parallel, and pin continuations so an agent loop keeps hitting the replica -# whose prefix cache holds its context. This seam drives the routing policy -# directly; the first version of these rules shipped a bug that only appeared on -# a continuation request, which is exactly the class of mistake a test this -# cheap should catch. - -load helpers - -ROUTING="$REPO_ROOT/dankserver/frank-medium/proxy/routing.py" -PROXY="$REPO_ROOT/dankserver/frank-medium/proxy/affinity-proxy.py" - -setup() { - DRIVER="$BATS_TEST_TMPDIR/route.py" - touch "$DRIVER" -} - -@test "the proxy and its routing policy exist and compile" { - [ -f "$ROUTING" ] - [ -f "$PROXY" ] - python3 -m py_compile "$ROUTING" - python3 -m py_compile "$PROXY" - # The policy must not drag the HTTP stack in: it is imported by tests and - # reasoned about without aiohttp, a network or a GPU. - ! grep -q 'import aiohttp' "$ROUTING" -} - -@test "a batch of single-turn requests spreads across both cards" { - cat > "$DRIVER" <<PY -import importlib.util, json, os, sys -os.environ["ROUTING"] = "adaptive" -spec = importlib.util.spec_from_file_location("routing", "$ROUTING") -mod = importlib.util.module_from_spec(spec) -sys.modules["routing"] = mod -spec.loader.exec_module(mod) -class R: - remote = "127.0.0.1" -shared = "shared document " + "y" * 8000 -picks = [] -for i in range(6): - text = shared if i % 2 else f"distinct prompt {i} " + "x" * 8000 - body = json.dumps({"messages": [{"role": "user", "content": text}]}).encode() - picks.append(mod.pick(*mod.affinity_key(body, R.remote), [True, True])) -print(" ".join(str(p) for p in picks)) -PY - run python3 "$DRIVER" - [ "$status" -eq 0 ] - read -r -a picks <<< "$output" - used=$(printf '%s\n' "${picks[@]}" | sort -u | wc -l) - # Both cards must work on a batch, or one card prefills an entire benchmark - # while the other idles. - [ "$used" -eq 2 ] - # Consecutive members alternate (the counter is advanced only by requests - # that are not continuations), including the two that share a document. - for ((i = 0; i < 5; i++)); do - [ "${picks[i]}" != "${picks[i + 1]}" ] - done -} - -@test "a continuation is pinned to the replica that served its prefix" { - cat > "$DRIVER" <<PY -import importlib.util, json, os, sys -os.environ["ROUTING"] = "adaptive" -spec = importlib.util.spec_from_file_location("routing", "$ROUTING") -mod = importlib.util.module_from_spec(spec) -sys.modules["routing"] = mod -spec.loader.exec_module(mod) -class R: - remote = "127.0.0.1" -prefix = "system prompt " + "z" * 8000 -first = json.dumps({"messages": [{"role": "user", "content": prefix + " turn one"}]}).encode() -start = mod.pick(*mod.affinity_key(first, R.remote), [True, True]) -# Same leading bytes, longer history and a different tail each turn: an agent -# loop resending its context. -turns = [] -history = [{"role": "assistant", "content": "ack"}] -for tail in ("turn two", "turn three", "turn four"): - history.append({"role": "user", "content": "question " + tail}) - body = json.dumps({"messages": [{"role": "user", "content": prefix + " turn one"}] + history}).encode() - turns.append(mod.pick(*mod.affinity_key(body, R.remote), [True, True])) - history.append({"role": "assistant", "content": "ack again"}) -print(start, " ".join(str(t) for t in turns)) -PY - run python3 "$DRIVER" - [ "$status" -eq 0 ] - read -r -a out <<< "$output" - # Every continuation agrees with the first turn's replica: that is where - # the prefix cache entry lives. - [ "${out[1]}" = "${out[0]}" ] - [ "${out[2]}" = "${out[0]}" ] - [ "${out[3]}" = "${out[0]}" ] -} - -@test "no routing mode ever picks a replica that is down" { - for mode in adaptive affinity roundrobin; do - cat > "$DRIVER" <<PY -import importlib.util, json, os, sys -os.environ["ROUTING"] = "$mode" -spec = importlib.util.spec_from_file_location("routing", "$ROUTING") -mod = importlib.util.module_from_spec(spec) -sys.modules["routing"] = mod -spec.loader.exec_module(mod) -class R: - remote = "127.0.0.1" -body = json.dumps({"messages": [{"role": "user", "content": "x" * 400}]}).encode() -one_down = mod.pick(*mod.affinity_key(body, R.remote), [False, True]) -both_down = mod.pick(*mod.affinity_key(body, R.remote), [False, False]) -print(one_down, both_down) -PY - run python3 "$DRIVER" - [ "$status" -eq 0 ] - # Replica 0 down -> replica 1; both down -> -1, and the proxy answers - # 503 rather than forwarding into the void. - [ "$output" = "1 -1" ] - done -} - -@test "affinity is deterministic per prefix, roundrobin is not" { - cat > "$DRIVER" <<PY -import importlib.util, json, os, sys -os.environ["ROUTING"] = "affinity" -spec = importlib.util.spec_from_file_location("routing", "$ROUTING") -mod = importlib.util.module_from_spec(spec) -sys.modules["routing"] = mod -spec.loader.exec_module(mod) -class R: - remote = "127.0.0.1" -def pick(text): - body = json.dumps({"messages": [{"role": "user", "content": text}]}).encode() - return mod.pick(*mod.affinity_key(body, R.remote), [True, True]) -doc = "d" * 8000 -# Same leading 4 KiB, different tails: affinity keys on the prefix, so both go -# to one replica. Different documents may hash anywhere — that is the point of -# hashing — so only the prefix property is asserted. -print(pick(doc + " a"), pick(doc + " b")) -PY - run python3 "$DRIVER" - [ "$status" -eq 0 ] - read -r -a aff <<< "$output" - [ "${aff[0]}" = "${aff[1]}" ] -} diff --git a/tests/.AGENTTRASH/frank_medium_quadlet.bats b/tests/.AGENTTRASH/frank_medium_quadlet.bats deleted file mode 100755 index 8c28799..0000000 --- a/tests/.AGENTTRASH/frank_medium_quadlet.bats +++ /dev/null @@ -1,164 +0,0 @@ -#!/usr/bin/env bats -# Seam: the frank-medium service on dankserver. -# frank-medium became a rootless Quadlet deployment on 2026-09-15 when the -# vllm-radiance MXFP4 stack replaced the charlie12345/ROCmFPX llama.cpp -# runtime on the 2x Radeon AI PRO R9700. The intended shape is one -# tensor-parallel process across both cards (frank-medium.container, kept as -# the target); it cannot run on this host because RCCL's kernels require PCIe -# atomics and the second card's chipset path advertises AtomicOpsCap: Routing-. -# The deployed shape is therefore two single-GPU replicas behind a round-robin -# proxy, which keeps the fleet contract of one :7770 endpoint. This seam pins -# the properties that make that work: GPU access on the fixed device indices, -# the upstream launcher's podman posture, host networking with the fleet's -# client port and alias, per-replica resource shape, boot persistence, and a -# deploy handler that moves the unusable TP=2 unit into the trash. - -load helpers - -UNIT_A="$REPO_ROOT/dankserver/frank-medium/quadlet/frank-medium-a.container" -UNIT_B="$REPO_ROOT/dankserver/frank-medium/quadlet/frank-medium-b.container" -UNIT_PROXY="$REPO_ROOT/dankserver/frank-medium/quadlet/frank-medium-proxy.container" -UNIT_TP2="$REPO_ROOT/dankserver/frank-medium/quadlet/frank-medium.container" -ENTRYPOINT="$REPO_ROOT/dankserver/frank-medium/radiance-entrypoint.sh" -HEALTHCHECK="$REPO_ROOT/dankserver/frank-medium/frank-medium-healthcheck.py" -PROXY="$REPO_ROOT/dankserver/frank-medium/proxy/affinity-proxy.py" -REMOTE_DEPLOY="$REPO_ROOT/scripts/deploy-remote.sh" - -@test "the frank-medium units live under dankserver/frank-medium/quadlet/" { - [ -f "$UNIT_A" ] - [ -f "$UNIT_B" ] - [ -f "$UNIT_PROXY" ] -} - -@test "the TP=2 unit is kept as the documented target, not deleted" { - # It becomes runnable again only on a host whose second card sits on an - # atomic-capable root port, so the artifact stays in the repo. - [ -f "$UNIT_TP2" ] - grep -q '^Environment=TP=2$' "$UNIT_TP2" -} - -@test "every frank-medium unit is boot-persistent (WantedBy=default.target)" { - # Generated units cannot be enabled on the podman 5.x v1 quadlet - # generator; [Install] plus linger is the entire boot-persistence story. - for unit in "$UNIT_A" "$UNIT_B" "$UNIT_PROXY"; do - grep -q '^WantedBy=default.target$' "$unit" - done -} - -@test "every frank-medium unit is self-healing" { - for unit in "$UNIT_A" "$UNIT_B" "$UNIT_PROXY"; do - grep -q '^Restart=on-failure$' "$unit" - grep -q '^RestartSec=' "$unit" - done -} - -@test "frank-medium units pin the image tag and never pull at boot" { - for unit in "$UNIT_A" "$UNIT_B" "$UNIT_PROXY"; do - grep -Eq '^Image=localhost/radiance-vllm:[0-9]+\.[0-9]+\.[0-9]+$' "$unit" - grep -q '^Pull=never$' "$unit" - done -} - -@test "the replicas each get exactly one GPU on the fixed device index" { - grep -q '^AddDevice=/dev/kfd$' "$UNIT_A" - grep -q '^AddDevice=/dev/dri$' "$UNIT_A" - # One index per replica: ROCR filters first and HIP indexes into the - # filtered list, so setting both to the same non-zero value leaves an - # empty device set. - grep -q '^Environment=HIP_VISIBLE_DEVICES=0$' "$UNIT_A" - grep -q '^Environment=HIP_VISIBLE_DEVICES=1$' "$UNIT_B" - ! grep -q '^Environment=ROCR_VISIBLE_DEVICES=' "$UNIT_A" - ! grep -q '^Environment=ROCR_VISIBLE_DEVICES=' "$UNIT_B" -} - -@test "the replicas run with the upstream launcher's podman posture" { - for unit in "$UNIT_A" "$UNIT_B"; do - grep -q -- '--privileged' "$unit" - grep -q -- '--ipc=host' "$unit" - grep -q -- '--security-opt seccomp=unconfined' "$unit" - grep -q -- '--network=host' "$unit" - done -} - -@test "the replicas serve single-GPU shapes that fit one card" { - for unit in "$UNIT_A" "$UNIT_B"; do - grep -q '^Environment=TP=1$' "$unit" - # 262144 does not fit one card (needs 9.61 GiB of KV, 5.14 available) - # and 131072 does not either (5.58 needed vs 5.22 available); 114688 - # is the largest measured shape that boots. - grep -q '^Environment=MAXLEN=114688$' "$unit" - # KV_MEM=0 makes vLLM profile this shape: the 2x7551-32624 pin is a - # TP=2/262144 measurement and invalid here. - grep -q '^Environment=KV_MEM=0$' "$unit" - grep -q '^Environment=SNAP=/models/Qwen3.8-27B-Uncensored-MXFP4-awq$' "$unit" - grep -q '^Environment=SPEC_METHOD=dflash$' "$unit" - grep -q '^Environment=SPEC=7$' "$unit" - done -} - -@test "the proxy keeps the fleet endpoint: host-network port 7770, alias frank-medium" { - grep -q '^Environment=PORT=7770$' "$UNIT_PROXY" - grep -q '^Environment=UPSTREAMS=http://127.0.0.1:7771,http://127.0.0.1:7772$' "$UNIT_PROXY" - grep -q -- '--network=host' "$UNIT_PROXY" - [ "$(grep -c '^PublishPort=' "$UNIT_PROXY")" -eq 0 ] - # The replicas answer as the same served model name, so a client cannot - # tell which one served a request. - grep -q '^Environment=SERVED_NAMES=frank-medium$' "$UNIT_A" - grep -q '^Environment=SERVED_NAMES=frank-medium$' "$UNIT_B" - grep -q '^Environment=PORT=7771$' "$UNIT_A" - grep -q '^Environment=PORT=7772$' "$UNIT_B" -} - -@test "frank-medium entrypoint is a syntax-clean fork of the upstream launcher" { - [ -f "$ENTRYPOINT" ] - bash -n "$ENTRYPOINT" - grep -q '9735329348ae1c9319f01cbe5f7004ec5f4dba63' "$ENTRYPOINT" -} - -@test "frank-medium healthcheck probes the serving endpoint on its own port" { - [ -f "$HEALTHCHECK" ] - python3 -m py_compile "$HEALTHCHECK" - # One probe serves the replicas, the proxy and the TP=2 target because it - # reads PORT instead of hardcoding 7770. - grep -q 'os.environ.get("PORT"' "$HEALTHCHECK" - for unit in "$UNIT_A" "$UNIT_B" "$UNIT_PROXY"; do - grep -q 'frank-medium-healthcheck.py' "$unit" - grep -q '^HealthStartPeriod=' "$unit" - done -} - -@test "the proxy unit mounts the routing policy and the server" { - # The policy lives in its own file so it can be tested without aiohttp; - # the server imports it by name from its own directory, so both must be - # mounted or the container dies on ModuleNotFoundError at start. - grep -q 'proxy/routing.py:/usr/local/bin/routing.py:ro' "$UNIT_PROXY" - grep -q 'proxy/affinity-proxy.py:/usr/local/bin/affinity-proxy.py:ro' "$UNIT_PROXY" - # Routing behaviour itself is covered by tests/frank_medium_proxy.bats, - # which drives the policy directly. - [ -f "$REPO_ROOT/tests/frank_medium_proxy.bats" ] -} -@test "the deploy handler installs the trio and retires the TP=2 unit" { - grep -q 'install_quadlet frank-medium-a dankserver/frank-medium/quadlet frank-medium-a' "$REMOTE_DEPLOY" - grep -q 'install_quadlet frank-medium-b dankserver/frank-medium/quadlet frank-medium-b' "$REMOTE_DEPLOY" - grep -q 'install_quadlet frank-medium-proxy dankserver/frank-medium/quadlet frank-medium-proxy' "$REMOTE_DEPLOY" - grep -q 'systemctl --user restart frank-medium-a.service frank-medium-b.service frank-medium-proxy.service' "$REMOTE_DEPLOY" - # The TP=2 unit cannot become healthy on this host; a leftover copy would - # leave the generator restarting it forever. It moves to the trash. - grep -q 'mv -- "$quadlet_unit_dir/frank-medium.container"' "$REMOTE_DEPLOY" - ! grep -qE '(^|[^[:alnum:]_])rm (-[a-zA-Z]+ )?"?\$quadlet_unit_dir/frank-medium' "$REMOTE_DEPLOY" -} - -@test "the deploy handler never tries to enable a generated unit" { - ! grep -q 'systemctl --user enable frank-medium' "$REMOTE_DEPLOY" -} - -@test "the deploy handler retires the old root unit without deleting it" { - grep -q 'retire_root_llm_medium' "$REMOTE_DEPLOY" - grep -q 'systemctl stop frank-medium.service' "$REMOTE_DEPLOY" - grep -q 'mv -- /etc/systemd/system/frank-medium.service' "$REMOTE_DEPLOY" - ! grep -qE '(^|[^[:alnum:]_])rm (-[a-zA-Z]+ )?/etc/systemd/system/frank-medium' "$REMOTE_DEPLOY" -} - -@test "the deploy handler re-tags the registry image to the localhost alias" { - grep -q 'podman tag docker.io/stilldeadcode/vllm-radiance:0.9.3 localhost/radiance-vllm:0.9.3' "$REMOTE_DEPLOY" -} From 0f4a527337e461cde6a66ada73d5cf431cee29a3 Mon Sep 17 00:00:00 2001 From: dank <dank@franksdank.cloud> Date: Sat, 31 Oct 2026 19:27:56 -0700 Subject: [PATCH 16/18] test(scoping): stop pinning a purgeable trash archive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seam asserted `[ -f scripts/.AGENTTRASH/chat-template.jinja.ik-llama-qwen-sharp ]` so that the planning assertion "could not pass by the file having vanished". That reasoning was wrong twice over: the planner's rule is a PATH rule, so it plans nothing for that path no matter where the bytes sit, and the trash is a purgeable hold — #111 untracked it, so a fresh worktree or CI checkout has no such file and the suite went red on a byte that carries no meaning. Measured: fresh worktree off origin/main, deployment_scoping.bats:39 fails. After the change: that file 28 ok / 0 fail, ./tests/run.sh 789 ok exit 0. --- tests/deployment_scoping.bats | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/deployment_scoping.bats b/tests/deployment_scoping.bats index f250030..02751c9 100644 --- a/tests/deployment_scoping.bats +++ b/tests/deployment_scoping.bats @@ -28,15 +28,16 @@ REMOTE_DEPLOY="$REPO_ROOT/scripts/deploy-remote.sh" ! grep -q 'frank_npu\|frank-npu' "$REMOTE_DEPLOY" } -@test "the retired ik chat template plans nothing, and its archive is still here" { +@test "the retired ik chat template plans nothing" { # scripts/chat-template.jinja existed only for the ik_llama.cpp runtime # (retired 2026-09-16 in favour of halogen-flash-server again). The # llama.cpp frank-zealot runtime serves the template the GGUF itself # carries, which is the one with reasoning_effort, preserve_thinking and - # the tool-call grammar. Editing the archive must deploy nothing -- and the - # archive must still be present, so this cannot pass by the file having - # vanished. - [ -f "$REPO_ROOT/scripts/.AGENTTRASH/chat-template.jinja.ik-llama-qwen-sharp" ] + # the tool-call grammar. The planner's rule is a PATH rule, so an edit at + # that path deploys nothing no matter where the bytes currently sit: the seam + # deliberately does not assert the archive exists. Trash is a purgeable hold + # (untracked in #111, and the deletion policy says the purger deletes it), so + # pinning it fails every fresh checkout and proves nothing about planning. run python3 "$PLANNER" --target dankclaw --paths scripts/chat-template.jinja [ "$status" -eq 0 ] [ -z "$output" ] From 6d79709cc0734b50251ea5f66352487d5b630ee9 Mon Sep 17 00:00:00 2001 From: dank <dank@franksdank.cloud> Date: Sat, 31 Oct 2026 19:33:16 -0700 Subject: [PATCH 17/18] fix(deploy): make the view's upper base before installing the view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ensure_media_view` ran `install` on a host where the documented base directory did not exist, and `install` refuses by design ("put it on the SSD first") — so tonight's cutover would have died at the restart step with nobody beside the box. `media-view.sh ensure-base` now makes the documented directory, and the deployer calls it first; two seams cover it (the subcommand's behaviour, and the ordering invariant inside `ensure_media_view`). Docs flip with the deploy: the view is live on Dankserver (both media servers verified resolving real media through it), so every "never been deployed" claim in the view, Plex, Jellyfin and AGENTS pages is corrected, and three laws join them — a container restart discards the in-flight library scan and nothing resumes it; Jellyfin 12.4 authenticates only the `MediaBrowser Token` form, so a 401 says nothing about the key until the `ApiKeys` table is read; and Plex answers 32400 over TLS only, which makes a plaintext health probe read as dead. --- AGENTS.md | 2 +- scripts/deploy-remote.sh | 4 +++ scripts/media-view.sh | 19 +++++++++++- serverAppConfigs-docs/Dankserver/Jellyfin.md | 20 ++++++------ .../Dankserver/Media Metadata View.md | 31 ++++++++++++------- tests/media_overlay.bats | 27 +++++++++++++++- 6 files changed, 77 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 363e88e..f3d4a40 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -860,6 +860,6 @@ history. | Benchmarking (only when ordered) | **Stop and mask the service first**, bench, unmask, restart, verify health. | | Deleting a file | **Move it to trash** — `.AGENTTRASH/` (in-repo) or `~/.AGENTTRASH/YYYYMMDD-topic`. Never `rm`. | | Relayout the movie library | **Census → plan → read the exceptions → `scripts/media-apply.py`** — renames only, journal before every move; never hand-rename, never copy. | -| Making media metadata visible to both media servers | **Bind `/var/lib/media-meta/view`** — the overlay `scripts/media-view.sh` installs (array below, NVMe above). Never write the array; never delete through the view. **Not yet deployed on Dankserver: the live quadlets still bind the array read-only — see [[Media Metadata View]].** | +| Making media metadata visible to both media servers | **Bind `/var/lib/media-meta/view`** — the overlay `scripts/media-view.sh` installs (array below, NVMe above). Never write the array; never delete through the view. **Deployed on Dankserver 2026-10-31 — both media servers bind it; a restart for any reason discards the in-flight library scan, so re-trigger the refresh — see [[Media Metadata View]].** | | Tests exist | **Run them** — verify tests pass after every change. Do not commit failing tests. | | Real secrets | **Never commit** — host `.env` files; `.env.example` holds placeholders only. | diff --git a/scripts/deploy-remote.sh b/scripts/deploy-remote.sh index 04790e5..a4f00a1 100755 --- a/scripts/deploy-remote.sh +++ b/scripts/deploy-remote.sh @@ -1212,6 +1212,10 @@ ensure_media_view() { # scripts/wait-for-mount.sh, which waits and then refuses the start. A # relayout must be followed by `media-view.sh reap` # (see the Media Relayout page), which this is not. + # A fresh host has no upper base, and `install` refuses to invent one — so + # without this the media deploy dies on a rebuilt machine with + # "put it on the SSD first" (measured 2026-10-31). + run_privileged bash scripts/media-view.sh ensure-base run_privileged bash scripts/media-view.sh install bash scripts/media-view.sh verify } diff --git a/scripts/media-view.sh b/scripts/media-view.sh index 125289f..9476fe4 100755 --- a/scripts/media-view.sh +++ b/scripts/media-view.sh @@ -60,7 +60,7 @@ die() { } usage() { - printf 'usage: %s plan|verify|install|reap|status [options]\n' "$0" >&2 + printf 'usage: %s plan|ensure-base|verify|install|reap|status [options]\n' "$0" >&2 exit 2 } @@ -98,6 +98,22 @@ require_upper_base() { || die "refused: upper base $upper_base does not exist (put it on the SSD first)" } +# The SSD placement of the metadata layer is a human choice, so `install` refuses +# to invent a base directory: a mistyped --upper-base must not quietly create a +# tree on whatever filesystem the caller landed on. The deployer owns the +# documented location, so it calls this first — measured 2026-10-31, when a +# fresh-host `install` died with "put it on the SSD first" and nothing in git +# would have created it. +do_ensure_base() { + if [ -d "$upper_base" ]; then + printf 'base: %s exists\n' "$upper_base" + return 0 + fi + install -d -m 0755 "$upper_base" 2>/dev/null \ + || die "refused: cannot create $upper_base — it lives outside the checkout, run this as root" + printf 'base: created %s\n' "$upper_base" +} + do_plan() { printf 'lowerdir=%s\nupperdir=%s\nworkdir=%s\nmountpoint=%s\nunit=%s/%s\n' \ "$lower" "$upper" "$work" "$VIEW" "$UNIT_DIR" "$UNIT" @@ -234,6 +250,7 @@ do_status() { case "$cmd" in plan) do_plan ;; verify) do_verify ;; + ensure-base) do_ensure_base ;; install) do_install ;; reap) do_reap ;; status) do_status ;; diff --git a/serverAppConfigs-docs/Dankserver/Jellyfin.md b/serverAppConfigs-docs/Dankserver/Jellyfin.md index d511ffd..890b32b 100644 --- a/serverAppConfigs-docs/Dankserver/Jellyfin.md +++ b/serverAppConfigs-docs/Dankserver/Jellyfin.md @@ -42,21 +42,20 @@ public vhost `jelly.franksdank.cloud` behind the [[Anubis]] guard chain → Library folders point at `/data` paths: stable across container recreation and identical to the raw array layout below. Which side of that path the container -is on is the open question on this host — the installed unit binds the read-only -array, and `scripts/media-view.sh status` reports `NOT MOUNTED` (2026-10-31). +is the [[Media Metadata View]] overlay, live since the 2026-10-31 cutover: +`/var/lib/media-meta/view:/data`, read-write. `Local metadata` — save metadata to media folders — is enabled, and it is the reason the view exists: `movie.nfo`, `tvshow.nfo`, `episode.nfo` and artwork -written once on the NVMe upper layer are files [[Plex]] can read too. While -`/data` is bound read-only every item update logs the error below and no sidecar -is written; Jellyfin becomes the fleet's only metadata writer with the deploy -that raises the view. +written once on the NVMe upper layer are files [[Plex]] can read too — and since +the 2026-10-31 cutover that is what happens: Jellyfin is the fleet's only +metadata writer, and every sidecar it saves is an SSD byte the array never sees. **Failure signature:** `System.IO.IOException: Read-only file system : '/data/Movies/<film>/movie.nfo'` means the container is binding the raw array -instead of the view. On Dankserver that is today's expected state — the view has -never been installed (`scripts/media-view.sh status`). Once it is deployed, the -same line is a fault: the quadlet drifted or the mount died. +instead of the view — always a fault on this host since the 2026-10-31 cutover. +`scripts/media-view.sh status` says which: the quadlet drifted back to the raw +array, or the mount died. ## Ports & access @@ -212,8 +211,7 @@ receives `Authorization: MediaBrowser Token=` with nothing after it (`\\"` answe One accepted refresh is the whole procedure. A second one while a scan runs moves `Scan Media Library` to `Cancelling`, and once the runner holds `Cancelling` every later `204` is accepted and then discarded — the status code stops meaning -anything. The fix is `systemctl --user restart jellyfin.service` (never -`deploy.sh`: that runs the never-tested metadata-view cutover), one POST after +anything. The fix is `systemctl --user restart jellyfin.service`, one POST after health answers, and then believe `ScheduledTasks` State at 130 % CPU, not the response code. diff --git a/serverAppConfigs-docs/Dankserver/Media Metadata View.md b/serverAppConfigs-docs/Dankserver/Media Metadata View.md index 3a06509..c17c10e 100644 --- a/serverAppConfigs-docs/Dankserver/Media Metadata View.md +++ b/serverAppConfigs-docs/Dankserver/Media Metadata View.md @@ -20,18 +20,22 @@ The mount point is not a volume and holds nothing while unmounted. It sits insid merged door are one directory — and so the mount table grows no new `/mnt` entry next to the array. -**Not deployed on Dankserver — verified 2026-10-31.** `scripts/media-view.sh -status` answers `view: /var/lib/media-meta/view NOT MOUNTED`, -`/var/lib/media-meta` does not exist, and the installed quadlets under -`~/.config/containers/systemd/` still carry the pre-view binds — `/mnt/entropy:/data:ro` -for [[Jellyfin]], `/mnt/entropy:/mnt/entropy:ro` for [[Plex]]. Everything below -is the shape git describes, not the shape the servers run. The cutover is an -ordinary deploy: `deploy.sh dankserver --service jellyfin|plex` runs -`ensure_media_view` (`install` then `verify`) before it restarts the container, -so it must not be fired through the middle of a library scan. Until that deploy -lands there is no upper layer, so no relayout can leave ghost metadata and -`reap` has nothing to do — `media-apply.py` prints that follow-up only when the -view really is a mountpoint. +**Deployed on Dankserver — 2026-10-31, both servers.** `plex` binds +`/var/lib/media-meta/view:/mnt/entropy:ro` and `jellyfin` binds +`/var/lib/media-meta/view:/data:rw`; `findmnt` reports the overlay with +`lowerdir=/mnt/entropy upperdir=/var/lib/media-meta/upper`, and each container +was proven to resolve a real media file through it — `podman exec <svc> ls -la +<its data path>` is the whole proof, because both servers keep the paths their +databases recorded. Two service-scoped deploys, each gated by +`media-view.sh verify` and the Playwright suite. + +Moving a bind means restarting the server, and **a restart discards that +server's in-flight library scan** — Jellyfin's 7 h 25 m walk ended +`"Status": "Aborted"` in `data/ScheduledTasks/<task-id>.js` and nothing resumes +it. Renames made on the array while the view was unmounted are invisible to both +watchers too, so the additive index has to be asked for by hand; the door is an +API key, and the row in the failure table below says what to check when it +refuses. ## Shape @@ -122,6 +126,9 @@ the defaults are the host values above. | Jellyfin log: `Read-only file system : '/data/Movies/…/movie.nfo'` | The container is binding the raw array, not the view — the quadlet drifted back or the view is gone. `media-view.sh status`. | | A folder appears in a media server with artwork/`.nfo` but no video | Ghost metadata from a relayout. `media-view.sh reap`. | | `verify` prints `refused: …/view is not a mountpoint` | A plain directory is sitting where the overlay belongs. That is the fail-closed guard working; `install` on the box. | +| `install` prints `refused: upper base /var/lib/media-meta does not exist (put it on the SSD first)` | A fresh host. `install` deliberately never creates the base — a mistyped `--upper-base` must not build a metadata tree on the wrong filesystem — so `scripts/media-view.sh ensure-base` makes the documented one, which the deployer calls first. | +| A media server's scan record reads `"Status": "Aborted"` | Its container restarted (a cutover, an image bump, an OOM) and the walk was discarded; systemd's `Restart=always` restarts the *server*, never the scan. Trigger a refresh — see [[Media Relayout]]. | +| The Jellyfin API answers 401 to every token header | Almost always the header **form**, not the key: 12.4 authenticates only `Authorization: MediaBrowser Token="…"` and refuses a bare `X-Emby-Token` for every key, valid or not — and one collapsed backslash in the curl config produces the same 401. `[[Jellyfin]]` owns the block that works. Before concluding anything, prove the key against the server's own table: `select count(*) from ApiKeys where AccessToken = ?` on `jellyfin.db` opened `mode=ro&immutable=1`. | | `systemctl status` finds no such unit | Look under the escaped name, `var-lib-media\x2dmeta-view.mount`, not the repo file name. | ## Rollback diff --git a/tests/media_overlay.bats b/tests/media_overlay.bats index 1bf526f..093d70f 100755 --- a/tests/media_overlay.bats +++ b/tests/media_overlay.bats @@ -10,7 +10,8 @@ # 4. both media units bind the VIEW and refuse to start without it; # 5. only those two units touch the view — the LLM and library tenants keep the # raw volume, so the blast radius is two containers; -# 6. the deployer brings the view up BEFORE it restarts either server; +# 6. the deployer creates the upper base and brings the view up BEFORE it +# restarts either server; # 7. the planner routes a change to these files to both media services. load helpers @@ -110,6 +111,30 @@ UNIT="$REPO_ROOT/dankserver/host/systemd/media-meta-view.mount" done } +@test "ensure-base creates a base directory that install refuses to invent" { + base="$BATS_TEST_TMPDIR/fresh-base" + run bash "$VIEW" ensure-base --upper-base "$base" + [ "$status" -eq 0 ] + [ -d "$base" ] + # The deployer runs this on every media deploy, so a second call must be a + # no-op and must say so rather than recreate anything. + run bash "$VIEW" ensure-base --upper-base "$base" + [ "$status" -eq 0 ] + [[ "${lines[0]}" == *"exists"* ]] +} + +@test "the media deployer creates the upper base before it installs the view" { + body="$(sed -n '/^ensure_media_view()/,/^\}/p' "$REPO_ROOT/scripts/deploy-remote.sh")" + [ -n "$body" ] + base="$(printf '%s\n' "$body" | grep -n 'media-view.sh ensure-base' | head -1 | cut -d: -f1)" + inst="$(printf '%s\n' "$body" | grep -n 'media-view.sh install' | head -1 | cut -d: -f1)" + # Order is the whole test: install refuses a missing base, so an installer + # that runs first dies on a rebuilt host (measured 2026-10-31). + [ -n "$base" ] + [ -n "$inst" ] + [ "$base" -lt "$inst" ] +} + @test "the planner routes the view files to both media services" { run python3 "$REPO_ROOT/scripts/deploy-plan.py" --target dankserver --paths \ scripts/media-view.sh dankserver/host/systemd/media-meta-view.mount From b7c19592e5f8c95e41581ec6f2808999b4279783 Mon Sep 17 00:00:00 2001 From: Frank <frank@dankware> Date: Sat, 31 Oct 2026 19:43:35 -0700 Subject: [PATCH 18/18] docs(jellyfin): record the live view bind and the library-coverage law MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page still described the pre-cutover generation ("the live unit is still pre-view", "/mnt/entropy -> /data (live today, read-only)") after the 2026-10-31 cutover deployed the overlay. Corrected, and added the law the census tonight taught: Jellyfin's four libraries cover /data/Movies, TVShows, Music and the collections dir, so anything under /data/media-park is invisible by configuration — a recursive refresh there returns 204 and indexes nothing. Check coverage before re-scanning. --- serverAppConfigs-docs/Dankserver/Jellyfin.md | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/serverAppConfigs-docs/Dankserver/Jellyfin.md b/serverAppConfigs-docs/Dankserver/Jellyfin.md index 890b32b..395545a 100644 --- a/serverAppConfigs-docs/Dankserver/Jellyfin.md +++ b/serverAppConfigs-docs/Dankserver/Jellyfin.md @@ -18,7 +18,7 @@ clients. public vhost `jelly.franksdank.cloud` behind the [[Anubis]] guard chain → [[Authentik]] outpost (post-auth route proxies `localhost:8096`). -## Facts (the repo quadlet; the live unit is still the pre-view generation — see Data layout) +## Facts (the repo quadlet — deployed as the live unit on 2026-10-31) | Item | Value | |------|-------| @@ -26,7 +26,7 @@ public vhost `jelly.franksdank.cloud` behind the [[Anubis]] guard chain → | Image | `ghcr.io/jellyfin/jellyfin:latest` | | Network | `dankserver_appnet`; publishes 8096/tcp, 8920/tcp, 7359/udp, 1900/udp | | Data roots | `/home/dank/Containers/jellyfin/config:/config`, `/home/dank/Containers/jellyfin/cache:/cache` | -| Media | `Volume=/var/lib/media-meta/view:/data` in git — the [[Media Metadata View]] overlay, **writable**, sidecars land on the NVMe and the [[Entropy Volume]] array underneath stays untouched. **The live unit is still pre-view: `/mnt/entropy:/data:ro`** — nothing is written into the library until the view is deployed | +| Media | `Volume=/var/lib/media-meta/view:/data` — the [[Media Metadata View]] overlay, **writable**, live since the 2026-10-31 cutover: sidecars land on the NVMe and the [[Entropy Volume]] array underneath stays untouched | | GPU | none — no `AddDevice`; CPU decode only (jellyfin-ffmpeg path) | | Env quirks | `DOTNET_SYSTEM_IO_DISABLEFILELOCKING=true`, `LD_PRELOAD=libjemalloc.so.2`, `MALLOC_TRIM_THRESHOLD_=131072` kept verbatim from the compose-era container (zero-drift cutover) | | Health | `curl -f http://localhost:8096/health` every 30 s | @@ -37,12 +37,12 @@ public vhost `jelly.franksdank.cloud` behind the [[Anubis]] guard chain → /home/dank/Containers/jellyfin/config -> /config (JELLYFIN_DATA_DIR) /home/dank/Containers/jellyfin/cache -> /cache (JELLYFIN_CACHE_DIR, XDG_CACHE_HOME) /var/lib/media-meta/view -> /data (git: the overlay view) -/mnt/entropy -> /data (live today: the array, read-only) +/mnt/entropy -> /data (the overlay's lower layer; also the rollback bind) ``` Library folders point at `/data` paths: stable across container recreation and identical to the raw array layout below. Which side of that path the container -is the [[Media Metadata View]] overlay, live since the 2026-10-31 cutover: +sees is the [[Media Metadata View]] overlay, live since the 2026-10-31 cutover: `/var/lib/media-meta/view:/data`, read-write. `Local metadata` — save metadata to media folders — is enabled, and it is the @@ -51,6 +51,15 @@ written once on the NVMe upper layer are files [[Plex]] can read too — and sin the 2026-10-31 cutover that is what happens: Jellyfin is the fleet's only metadata writer, and every sidecar it saves is an SSD byte the array never sees. +**Library coverage is the first thing to check when a title is missing.** The +four libraries are `movies → /data/Movies`, `tvshows → /data/TVShows`, +`music → /data/Music`, `boxsets → /config/data/collections` +(`GET /Library/VirtualFolders`). `/data/media-park` is in **none** of them, so +everything filed under it — the `_unsorted` release bundles and the `extras/` +folders the relayout wrote there — is invisible to Jellyfin by configuration, +not by fault: a recursive refresh answers 204, burns CPU for minutes and +indexes nothing, because it never looks there. Check coverage before re-scanning. + **Failure signature:** `System.IO.IOException: Read-only file system : '/data/Movies/<film>/movie.nfo'` means the container is binding the raw array instead of the view — always a fault on this host since the 2026-10-31 cutover.