From 5dc3ff4d6039dcb75500899ce072d41128e71996 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 11 Oct 2026 19:20:09 -0500 Subject: [PATCH 01/29] fix(caches): #838 #839 #840 #841 - clear holds the job queue; sweep orphaned staging; memoize scan_all; quiet rescan logging Co-Authored-By: Claude Sonnet 5.5 --- dw/disk_caches.py | 68 ++++++++++++-- dw/server/jobs.py | 28 ++++++ dw/server/routes/system.py | 42 +++++---- tests/test_disk_caches.py | 142 ++++++++++++++++++++++++++++- ui/src/lib/generated/api-schema.ts | 2 +- ui/src/lib/generated/openapi.json | 2 +- 6 files changed, 255 insertions(+), 29 deletions(-) diff --git a/dw/disk_caches.py b/dw/disk_caches.py index a70b12c9..553e3540 100644 --- a/dw/disk_caches.py +++ b/dw/disk_caches.py @@ -33,7 +33,11 @@ is renamed into one `/.clearing--/` directory, which is then deleted. A writer racing the clear finds an empty cache and rebuilds; it never reads a half-deleted tree. An entry delete moves the one entry the same way. The -routes refuse while a job is running or queued. +routes refuse while a job is running or queued, and hold the job queue still +(`JobManager.hold_dispatch`) while they work, so a job submitted meanwhile waits +instead of starting into a root being emptied. A staging directory a killed +server left behind is removed at `mark_roots()` and at the next clear, and is +never counted by a scan. `scan_all()` reuses its answer for a few seconds. **An entry id must have a cache key's shape** (`quantized_cache.is_key`, 64 lowercase hex) before any path is built from it. @@ -45,6 +49,7 @@ import secrets import shutil import threading +import time from dataclasses import dataclass from typing import Callable @@ -65,6 +70,12 @@ _clear_lock = threading.Lock() +# How long scan_all's answer is reused. A scan stat()s every file of every +# cache, and get_server_info asks on each call (#840) +_SCAN_TTL = 5.0 +_scan_memo = None # (monotonic time, scan_all's result) + + class UnknownCache(ValueError): pass @@ -210,9 +221,28 @@ def _write_marker(root): f.write(_MARKER_TEXT) -def mark_root(cache): +def _sweep_staging(root): + """Delete the `.clearing-*` directories a clear that died mid-delete left + in `root`. Only called where no clear can be running: server start, or + inside `_clear_lock`.""" + try: + leftovers = [ + entry.path + for entry in _children(root) + if entry.name.startswith(_CLEARING_PREFIX) + and entry.is_dir(follow_symlinks=False) + ] + except OSError: + return + for path in leftovers: + shutil.rmtree(path, ignore_errors=True) + logger.info(f"Removed the leftover {path} from an interrupted clear") + + +def mark_root(cache, quiet=False): """Mark one cache's root as dw's to clear, if the rule allows it. - Returns whether the root carries the marker afterwards.""" + Returns whether the root carries the marker afterwards. `quiet` logs the + refusal at DEBUG: only the startup pass says it at INFO (#841).""" root = cache.root() if os.path.islink(root) or (os.path.exists(root) and not os.path.isdir(root)): return False @@ -223,9 +253,10 @@ def mark_root(cache): if _has_marker(root): return True if _children(root) and root != cache.default_root(): - logger.info( + logger.log( + logging.DEBUG if quiet else logging.INFO, f"The {cache.name} cache at {root} was not created by dw and " - f"has no {MARKER} marker, so it can't be cleared from the server" + f"has no {MARKER} marker, so it can't be cleared from the server", ) return False _write_marker(root) @@ -237,7 +268,8 @@ def mark_roots(): once at server start; a root it can't write is left unclearable.""" for cache in CACHES.values(): try: - mark_root(cache) + if mark_root(cache): + _sweep_staging(cache.root()) except (OSError, SecurityError) as e: logger.warning(f"Could not mark the {cache.name} cache: {e}") @@ -255,6 +287,9 @@ def _walk(top): continue with entries: for entry in entries: + if directory == top and entry.name.startswith(_CLEARING_PREFIX): + # A clear's staging directory is on its way out + continue try: if entry.is_dir(follow_symlinks=False): stack.append(entry.path) @@ -295,7 +330,7 @@ def _reason(cache): root = cache.root() if os.path.isdir(root) and not _has_marker(root): try: - mark_root(cache) + mark_root(cache, quiet=True) except (OSError, SecurityError) as e: logger.warning(f"Could not mark the {cache.name} cache: {e}") return _not_clearable_reason(root) @@ -385,7 +420,20 @@ def scan_entries(root): def scan_all(): - return [scan(cache) for cache in CACHES.values()] + """Every cache as plain data, reused for `_SCAN_TTL` seconds; a clear or + entry delete drops the reused answer.""" + global _scan_memo + memo = _scan_memo + if memo is not None and time.monotonic() - memo[0] < _SCAN_TTL: + return memo[1] + result = [scan(cache) for cache in CACHES.values()] + _scan_memo = (time.monotonic(), result) + return result + + +def _forget_scans(): + global _scan_memo + _scan_memo = None def entry_cache(name, entry): @@ -441,10 +489,12 @@ def delete_entry(name, entry): if reason: raise NotClearable(f"The {name} cache at {root} can't be changed: {reason}") root = validate_path(root) + _sweep_staging(root) path = quantized_cache.entry_path(root, entry) if os.path.islink(path) or not os.path.isdir(path): raise UnknownEntry(f"The {name} cache has no entry {entry}") freed, files = _stage_out(root, [path]) + _forget_scans() logger.info(f"Deleted {name} cache entry {entry} ({freed} bytes, {files} files)") return {"freed": freed, "files": files} @@ -462,10 +512,12 @@ def clear(name): if reason: raise NotClearable(f"The {name} cache at {root} can't be cleared: {reason}") root = validate_path(root) + _sweep_staging(root) # A link is left alone: validate_path resolves it, so the rename # would move its target, not the link freed, files = _stage_out( root, [entry.path for entry in _children(root) if not entry.is_symlink()] ) + _forget_scans() logger.info(f"Cleared the {name} cache at {root} ({freed} bytes, {files} files)") return {"freed": freed, "files": files} diff --git a/dw/server/jobs.py b/dw/server/jobs.py index 6b648e30..3fd0b6e0 100644 --- a/dw/server/jobs.py +++ b/dw/server/jobs.py @@ -38,6 +38,7 @@ import time import logging import threading +from contextlib import contextmanager from ..worker_protocol import Execute, workflow_identity from ..devices import device_ordinal @@ -79,6 +80,10 @@ TERMINAL_JOBS_KEPT = 20 +class JobsActive(Exception): + """A job is running or queued.""" + + class JobManager: """Queues jobs and dispatches them onto one worker process per card.""" @@ -126,6 +131,9 @@ def __init__( self._needs = {} # The card a rerun prefers - the original's ordinal - by job id self._preferred = {} + # Callers holding dispatch (hold_dispatch): while any does, no queued + # job is started, though jobs may still be submitted + self._dispatch_holds = 0 self._stop = threading.Event() # The thread running each dispatched job, joined by shutdown() self._job_threads = set() @@ -705,6 +713,8 @@ def _next_dispatch(self): None. A job too big for every free card keeps its place, and the scan goes on past it: a smaller job behind it takes the card meanwhile (backfill). Called holding _lock.""" + if self._dispatch_holds: + return None free = [slot for slot in self.slots if slot.current_job_id is None] if not free: return None @@ -846,6 +856,24 @@ def is_busy(self): return True return any(job.status == QUEUED for job in self.jobs.values()) + @contextmanager + def hold_dispatch(self): + """Hold the queue still while the caller changes files a job writes + or reads. Raises JobsActive if a job is running or queued; otherwise + no job starts until the block exits - one submitted meanwhile waits. + The check and the hold are one step under the lock, so no job can + slip in between them.""" + with self._lock: + if self.is_busy(): + raise JobsActive() + self._dispatch_holds += 1 + try: + yield + finally: + with self._wake: + self._dispatch_holds -= 1 + self._wake.notify() + def is_worker_busy(self, slot): """True while `slot`'s card is running a job.""" with self._lock: diff --git a/dw/server/routes/system.py b/dw/server/routes/system.py index 10711f72..98470864 100644 --- a/dw/server/routes/system.py +++ b/dw/server/routes/system.py @@ -8,6 +8,7 @@ """ import logging +from contextlib import contextmanager from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query, Request @@ -49,7 +50,7 @@ from .. import guides from ..deps import selected_workspace from ..guides import GuideError -from ..jobs import WorkerBusy +from ..jobs import JobsActive, WorkerBusy from ..netinfo import local_addresses from ..sysinfo import runtime_info @@ -278,14 +279,14 @@ def list_caches(): ) def clear_cache(request: Request, name: str): """Empty one cache's root, keeping the root and its marker. Refused - while anything may be writing into it (`_refuse_while_writing`).""" + while anything may be writing into it (`_hold_writers_off`).""" try: disk_caches.get(name) except disk_caches.UnknownCache as e: raise HTTPException(status_code=404, detail=str(e)) - _refuse_while_writing(request, name) try: - result = disk_caches.clear(name) + with _hold_writers_off(request, name): + result = disk_caches.clear(name) except disk_caches.NotClearable as e: raise HTTPException(status_code=400, detail=str(e)) return {"name": name, "cleared": True, **result} @@ -305,9 +306,9 @@ def delete_cache_entry(request: Request, name: str, entry: str): raise HTTPException(status_code=404, detail=str(e)) except disk_caches.NoEntries as e: raise HTTPException(status_code=400, detail=str(e)) - _refuse_while_writing(request, name) try: - result = disk_caches.delete_entry(name, entry) + with _hold_writers_off(request, name): + result = disk_caches.delete_entry(name, entry) except disk_caches.UnknownEntry as e: raise HTTPException(status_code=404, detail=str(e)) except disk_caches.NotClearable as e: @@ -320,24 +321,31 @@ def delete_cache_entry(request: Request, name: str, entry: str): _DOWNLOAD_SENSITIVE_CACHES = frozenset({"quantized"}) -def _refuse_while_writing(request, name): - """409 while a job is running or queued - a compile, autotune or - quantized save in progress writes into the root, and a delete mid-write - could leave a truncated artifact a later lookup trusts - and, for the - quantized cache, while a model download is active.""" +@contextmanager +def _hold_writers_off(request, name): + """Hold off whatever may write into cache `name` for the block: 409 while + a job is running or queued - a compile, autotune or quantized save in + progress writes into the root, and a delete mid-write could leave a + truncated artifact a later lookup trusts - and, for the quantized cache, + while a model download is active. A job submitted during the block waits + in the queue until it ends: the busy check and the delete are not two + steps a submit can fall between.""" state = request.app.state - if state.job_manager.is_busy(): - raise HTTPException( - status_code=409, - detail="A job is running or queued - clearing a cache it may be " - "writing would leave it a truncated artifact", - ) if name in _DOWNLOAD_SENSITIVE_CACHES and state.downloads.is_active(): raise HTTPException( status_code=409, detail="A model download is in progress - the quantized cache " "can't be changed until it finishes", ) + try: + with state.job_manager.hold_dispatch(): + yield + except JobsActive: + raise HTTPException( + status_code=409, + detail="A job is running or queued - clearing a cache it may be " + "writing would leave it a truncated artifact", + ) # ------------------------------------------------------ diffusers update diff --git a/tests/test_disk_caches.py b/tests/test_disk_caches.py index 77a8c9e4..f12ae481 100644 --- a/tests/test_disk_caches.py +++ b/tests/test_disk_caches.py @@ -2,8 +2,10 @@ import builtins import json +import logging import os import sys +import time import pytest from fastapi.testclient import TestClient @@ -17,9 +19,14 @@ UnknownEntry, ) from dw.server.app import create_app -from dw.server.jobs import JobManager +from dw.server.jobs import JobManager, JobsActive -from tests.test_server import ScriptedWorkerManager, success_script +from tests.test_server import ( + ScriptedWorkerManager, + admitted_for, + success_script, + valid_workflow, +) @pytest.fixture @@ -39,6 +46,11 @@ def env(tmp_path, monkeypatch): return home +@pytest.fixture(autouse=True) +def fresh_scans(monkeypatch): + monkeypatch.setattr(disk_caches, "_scan_memo", None) + + def populate(root, sizes=(10, 20, 30)): """Files in the root and a nested directory; returns (bytes, files).""" root.mkdir(parents=True, exist_ok=True) @@ -767,3 +779,129 @@ def test_triton_default_created_after_start_is_clearable(env): assert info["clearable"] is True assert info["files"] > 0 assert disk_caches.clear("triton")["files"] > 0 + + +# ------------------------------------------- the clear holds the queue (#838) + + +def test_job_submitted_during_a_clear_waits_for_it(api, tmp_path, monkeypatch): + root, _ = marked_root(tmp_path) + manager = api.manager + seen = {} + real_clear = disk_caches.clear + + def clear_with_a_submit_in_the_window(name): + # Between the busy check and the clear: a job arrives + seen["job"] = manager.submit( + admitted=admitted_for(manager, valid_workflow()), + workflow=valid_workflow(), + arguments={"prompt": "hi"}, + ) + time.sleep(0.3) + seen["status_during"] = seen["job"].status + return real_clear(name) + + monkeypatch.setattr(disk_caches, "clear", clear_with_a_submit_in_the_window) + + response = api.delete("/api/caches/inductor") + + assert response.status_code == 200 + assert seen["status_during"] == "queued" + job = seen["job"] + deadline = time.time() + 5 + while job.status not in ("succeeded", "failed") and time.time() < deadline: + time.sleep(0.02) + assert job.status == "succeeded" + assert tree(root) == [MARKER] + + +def test_hold_dispatch_refuses_when_busy_and_releases(api): + manager = api.manager + manager.is_busy = lambda: True + with pytest.raises(JobsActive): + with manager.hold_dispatch(): + pass + assert manager._dispatch_holds == 0 + del manager.is_busy + with manager.hold_dispatch(): + assert manager._dispatch_holds == 1 + assert manager._dispatch_holds == 0 + + +# --------------------------------------------- orphaned staging (#839) + + +def test_scan_does_not_count_a_leftover_staging_directory(env, tmp_path): + root, (total, count) = marked_root(tmp_path) + leftover = root / ".clearing-1-abcd" + leftover.mkdir() + (leftover / "big.bin").write_bytes(b"x" * 500) + + info = disk_caches.scan(disk_caches.get("inductor")) + + assert (info["bytes"], info["files"]) == (total, count) + + +def test_mark_roots_and_clear_remove_a_leftover_staging_directory(env, tmp_path): + root, _ = marked_root(tmp_path) + leftover = root / ".clearing-1-abcd" + leftover.mkdir() + (leftover / "big.bin").write_bytes(b"x" * 500) + + disk_caches.mark_roots() + assert not leftover.exists() + assert (root / "a.bin").exists() + + again = root / ".clearing-2-ef01" + again.mkdir() + (again / "big.bin").write_bytes(b"x" * 500) + result = disk_caches.clear("inductor") + assert not again.exists() + assert tree(root) == [MARKER] + assert result["files"] == 3 + + +# ------------------------------------------------- scan cost (#840) + + +def test_scan_all_reuses_its_answer_until_a_clear(env, tmp_path): + root, (total, _) = marked_root(tmp_path) + + first = disk_caches.scan_all() + (root / "late.bin").write_bytes(b"x" * 7) + assert disk_caches.scan_all() is first + + disk_caches.clear("inductor") + after = {c["name"]: c for c in disk_caches.scan_all()} + assert after["inductor"]["bytes"] == 0 + + +def test_scan_all_rescans_after_the_ttl(env, tmp_path, monkeypatch): + root, (total, _) = marked_root(tmp_path) + disk_caches.scan_all() + (root / "late.bin").write_bytes(b"x" * 7) + monkeypatch.setattr(disk_caches, "_SCAN_TTL", 0.0) + + inductor = {c["name"]: c for c in disk_caches.scan_all()}["inductor"] + + assert inductor["bytes"] == total + 7 + + +# ---------------------------------- the refusal is logged once (#841) + + +def test_unmarked_root_refusal_is_info_at_startup_and_quiet_on_scans( + env, tmp_path, caplog +): + populate(tmp_path / "inductor") + cache = disk_caches.get("inductor") + + with caplog.at_level(logging.INFO, logger="dw"): + disk_caches.mark_roots() + startup = [r for r in caplog.records if "not created by dw" in r.message] + caplog.clear() + disk_caches.scan(cache) + disk_caches.scan_all() + + assert len(startup) >= 1 + assert not [r for r in caplog.records if "not created by dw" in r.message] diff --git a/ui/src/lib/generated/api-schema.ts b/ui/src/lib/generated/api-schema.ts index dd681ac6..07eea865 100644 --- a/ui/src/lib/generated/api-schema.ts +++ b/ui/src/lib/generated/api-schema.ts @@ -158,7 +158,7 @@ export interface paths { /** * Clear Cache * @description Empty one cache's root, keeping the root and its marker. Refused - * while anything may be writing into it (`_refuse_while_writing`). + * while anything may be writing into it (`_hold_writers_off`). */ delete: operations["clear_cache_api_caches__name__delete"]; options?: never; diff --git a/ui/src/lib/generated/openapi.json b/ui/src/lib/generated/openapi.json index 7c5b9851..dbe30f94 100644 --- a/ui/src/lib/generated/openapi.json +++ b/ui/src/lib/generated/openapi.json @@ -4967,7 +4967,7 @@ }, "/api/caches/{name}": { "delete": { - "description": "Empty one cache's root, keeping the root and its marker. Refused\nwhile anything may be writing into it (`_refuse_while_writing`).", + "description": "Empty one cache's root, keeping the root and its marker. Refused\nwhile anything may be writing into it (`_hold_writers_off`).", "operationId": "clear_cache_api_caches__name__delete", "parameters": [ { From 9c94504ae36483b41db0729b6515d19169d2c83d Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 11 Oct 2026 19:23:30 -0500 Subject: [PATCH 02/29] fix(caches): #842 - record the component name in the quantized cache entry.json Co-Authored-By: Claude Sonnet 5.5 --- dw/disk_caches.py | 2 +- dw/quantized_cache.py | 18 ++++++++++++++---- tests/test_quantized_cache.py | 3 ++- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/dw/disk_caches.py b/dw/disk_caches.py index 553e3540..c5921528 100644 --- a/dw/disk_caches.py +++ b/dw/disk_caches.py @@ -375,7 +375,7 @@ def scan(cache): def _entry_fields(path): """What an entry's `entry.json` says of it, or None when it can't be read. `component` falls back to the subfolder - the component's name in - a diffusers repo - since entry.json doesn't record it.""" + a diffusers repo - for entries written before entry.json recorded it.""" try: entry = quantized_cache.read_entry(path) except (OSError, ValueError): diff --git a/dw/quantized_cache.py b/dw/quantized_cache.py index 16224913..ac43dc00 100644 --- a/dw/quantized_cache.py +++ b/dw/quantized_cache.py @@ -436,8 +436,9 @@ def load_entry(root, key, model_cls, torch_dtype, device, config_arguments): return component -def save_entry(root, key, component, inputs): - """Save a just-quantized component as `key`. Returns (result, reason, +def save_entry(root, key, component, inputs, name=None): + """Save a just-quantized component as `key`, recording its `name` in + entry.json (the Caches panel labels the entry with it). Returns (result, reason, bytes) for the event: `saved`, or `skipped` with why. Never raises.""" estimate = component_nbytes(component) try: @@ -466,7 +467,14 @@ def save_entry(root, key, component, inputs): now = time.time() _write_json( os.path.join(tmp, ENTRY_FILE), - {**inputs, "key": key, "bytes": nbytes, "created": now, "last_hit": None}, + { + **inputs, + "component": name, + "key": key, + "bytes": nbytes, + "created": now, + "last_hit": None, + }, ) try: os.rename(tmp, final) @@ -907,7 +915,9 @@ def _save(root, candidate, component): return inputs = candidate.inputs(commit) started = time.monotonic() - result, reason, nbytes = save_entry(root, cache_key(inputs), component, inputs) + result, reason, nbytes = save_entry( + root, cache_key(inputs), component, inputs, candidate.name + ) emit( candidate.name, result, diff --git a/tests/test_quantized_cache.py b/tests/test_quantized_cache.py index 878c4429..50a44387 100644 --- a/tests/test_quantized_cache.py +++ b/tests/test_quantized_cache.py @@ -1100,9 +1100,10 @@ def test_save_entry_then_load_entry_reproduces_the_forward_pass( root = str(tmp_path / "cache") key = qc.cache_key(inputs) - result, reason, nbytes = qc.save_entry(root, key, quantized, inputs) + result, reason, nbytes = qc.save_entry(root, key, quantized, inputs, "unet") assert (result, reason) == ("saved", None) and nbytes > 0 assert qc.read_entry(os.path.join(root, key))["model_name"] == "org/tiny" + assert qc.read_entry(os.path.join(root, key))["component"] == "unet" loaded = qc.load_entry(root, key, UNet2DModel, torch.float32, "cpu", arguments) assert loaded is not None From ca730fd66b23e0e495f66be69652c9e8eb41058d Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 11 Oct 2026 19:26:01 -0500 Subject: [PATCH 03/29] feat(loras): #824 - record the 4-step Ref2VA v0.1 turbo LoRA as a trial Co-Authored-By: Claude Sonnet 5.5 --- loras/minimax-h3/turbo-4step-reference.json | 32 +++++++++++++++++++ .../references/checkpoints-and-loras.md | 6 ++-- 2 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 loras/minimax-h3/turbo-4step-reference.json diff --git a/loras/minimax-h3/turbo-4step-reference.json b/loras/minimax-h3/turbo-4step-reference.json new file mode 100644 index 00000000..b99b924c --- /dev/null +++ b/loras/minimax-h3/turbo-4step-reference.json @@ -0,0 +1,32 @@ +{ + "model_name": "lightx2v/Minimax-h3-Turbo", + "weight_name": "minimax_h3_ref2v_turbo_4step_v0.1_bf16.safetensors", + "revision": "3ec17a324ced54151364f24f8b5fb6bf7e26414f", + "base_models": [ + "MiniMaxAI/MiniMax-H3" + ], + "workflow": "ref2va", + "description": "4-step turbo v0.1 for the reference partition (ref2va); header records alpha 8, no training canvas", + "use_when": "Optional faster Ref2VA trial: pair with num_inference_steps 5 and shift 12/3, never on a t2va/fl2va step. Keep the 8-step file as the default (#824)", + "scale": { + "default": 1.0 + }, + "status": "trial", + "evidence": [ + { + "note": "960x544 Ref2VA shot, 124 frames, reference image + audio, seed 11, int4 sdnq, shift 12/3, 5 steps: clean render, identity and mouth motion held, no noise. Denoise ~222s vs ~384s for the 8-step file at 9 steps (~42% less); s/step unchanged (~44 vs ~43). Model card gives no shift. Judged from frames only (audio not auditioned); one shot, one seed", + "issue": 824, + "job": "ec9f5b3b92e5" + }, + { + "note": "Baseline: 8-step v1.0 768p file, 9 steps, shift 12/3, same shot and seed", + "issue": 824, + "job": "d65cb4586b9c" + } + ], + "license": "apache-2.0", + "tags": [ + "turbo", + "speed" + ] +} diff --git a/plugins/dw/skills/minimax-h3/references/checkpoints-and-loras.md b/plugins/dw/skills/minimax-h3/references/checkpoints-and-loras.md index 73af8423..dc510fe4 100644 --- a/plugins/dw/skills/minimax-h3/references/checkpoints-and-loras.md +++ b/plugins/dw/skills/minimax-h3/references/checkpoints-and-loras.md @@ -12,8 +12,10 @@ shift **6**/3, `num_inference_steps=5`, alpha unset, on every T2VA/FL2VA template at 960x544 and 1344x768 (since 2026-10-10; trained at 768p, measured at 544p on one prompt). 544p FL2VA 8-step, 960x544, shift 12/3, 9 steps - the slower alternative; 768p FL2VA 8-step, 1344x768, shift 6/3, -9 steps; 768p Ref2VA 8-step, shift 12/3, 9 steps - every `ref2va` template -(no tested 4-step Ref2VA file). Swap file, shift and steps together. The +9 steps; 768p Ref2VA 8-step, shift 12/3, 9 steps - every `ref2va` template. +4-step Ref2VA v0.1 (`..._ref2v_turbo_4step_v0.1_bf16`, 5 steps, shift 12/3) is +a trial (#824): clean on one shot, ~42% less denoise time, not yet a default. +Swap file, shift and steps together. The 544p 4-step v0.1 file renders noise. v1.2 records alpha 8; v1.0/v1.1 record 128 - never state an alpha the header doesn't. From d5305df162c92bcb7e4a62bd823cddc722cdcb87 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 11 Oct 2026 19:26:15 -0500 Subject: [PATCH 04/29] fix(cuts): #843 - take the fallback song length from non-suspect chunks Co-Authored-By: Claude Sonnet 5.5 --- dw/tasks/cuts.py | 7 +++++-- tests/test_plan_cuts.py | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/dw/tasks/cuts.py b/dw/tasks/cuts.py index c5613dca..46bf1214 100644 --- a/dw/tasks/cuts.py +++ b/dw/tasks/cuts.py @@ -841,12 +841,15 @@ def _song_length(args, transcript, beats, beats_duration, warnings): if duration is None: # Only the transcript says how long: to its last word, which is no # end at all when that word's end is null - if not chunks or chunks[-1]["end"] is None: + # a chunk left out of the lines (a hallucination over the fade-out) + # doesn't say where the song ends either + kept = [c for c in chunks if c.get("suspect") not in SUSPECT_LEFT_OUT] + if not kept or kept[-1]["end"] is None: raise ValueError( f"{COMMAND} can't tell how long the song is - pass 'duration_s', " "or the analyze_beats result as 'beats'" ) - duration = max(chunk["end"] for chunk in chunks) + duration = max(chunk["end"] for chunk in kept) if beats is None: why = "no 'duration_s' or beats" else: diff --git a/tests/test_plan_cuts.py b/tests/test_plan_cuts.py index 0e9d512c..5a0fd87f 100644 --- a/tests/test_plan_cuts.py +++ b/tests/test_plan_cuts.py @@ -809,3 +809,22 @@ def test_a_good_or_deferred_grid_passes(self): ) == [] ) + + +def test_fallback_length_ignores_a_suspect_tail_chunk(): + tail = { + "start": 24.0, + "end": 40.0, + "text": "Thanks for watching", + "suspect": "silent", + } + transcript = {"text": TRANSCRIPT["text"], "chunks": [*CHUNKS, tail]} + result = plan_cuts(transcript=transcript, beats=beat_list()) + assert result["duration_s"] == 24.0 + assert any("at 24.00 s" in w for w in result["warnings"]) + + +def test_fallback_length_refuses_a_transcript_of_only_suspect_chunks(): + chunks = [{"start": 0.0, "end": 9.0, "text": "—", "suspect": "no_words"}] + with pytest.raises(ValueError, match="can't tell how long"): + plan_cuts(transcript={"text": "", "chunks": chunks}, beats=beat_list()) From 68ba49b77d9f3185dd5909a7aefe91e94d36d220 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 11 Oct 2026 19:29:12 -0500 Subject: [PATCH 05/29] fix(ui): #844 - caches table size cell uses cacheSize ('0 bytes' for empty) Co-Authored-By: Claude Sonnet 5.5 --- ui/src/lib/CachesPanel.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/lib/CachesPanel.svelte b/ui/src/lib/CachesPanel.svelte index aff6cae7..41db3249 100644 --- a/ui/src/lib/CachesPanel.svelte +++ b/ui/src/lib/CachesPanel.svelte @@ -132,7 +132,7 @@ {cache.name} {cache.root} - {cache.bytes ? formatBytes(cache.bytes) : '0'} + {cacheSize(cache.bytes)} {cache.files} {cache.last_used ? formatMtime(cache.last_used) : '—'} From 88064f3a1b0597b271124aa40ac7aaff3301c3c3 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 11 Oct 2026 19:41:56 -0500 Subject: [PATCH 06/29] docs(arch): #838 - map names JobManager.hold_dispatch and the queued-during-clear rule Co-Authored-By: Claude Sonnet 5.5 --- docs/ARCHITECTURE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e292d907..43f13588 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -150,10 +150,10 @@ one to open. | App factory | `dw/server/app.py`: `create_app` | `create_app` builds the JobManager and `app.state`, installs the middleware and registers the routes, and all state lives in the JobManager. | — | | Routers | `dw/server/routes/*.py`, `dw/server/routes/__init__.py`: `ROUTERS`, `include_routers`, `include_file_routes` | Routers register in `ROUTERS` order, because a greedy `{name:path}` route must come after its specific siblings. The files router comes later, after `/mcp`. | `tests/test_server_downloads.py::test_download_workflow_sets_content_disposition_attachment` | | Job queue | `dw/server/jobs.py`: `JobManager`; `dw/server/job_results.py`: `consume_results` | A dispatcher thread hands queued jobs, in queue order, to the pool's workers (`Worker pool dispatch`); each worker runs one job at a time, and `consume_results` folds that worker's replies into the job, naming files relative to the job's own `output_dir`. A job carries its own `output_dir`, `asset_dir` and `workflow_dir`, so it stays in its workspace. | `tests/test_server_workspaces.py::TestRunning::test_rerun_stays_in_the_workspace_it_ran_in` (FIFO: —) | -| Worker pool dispatch | `dw/server/jobs.py`: `JobManager._next_dispatch`; `dw/server/pool.py`: `WorkerSlot`, `check_fits`, `largest_ceiling_gb`; `dw/vram_estimate.py`: `required_vram_gb`; `dw/server/admission.py`: `pool_capacity` | `--devices` runs one worker per card; a queued job goes to a free card that fits what it needs, a job that fits no free card keeps its place while a smaller one behind it takes the card, a job bigger than every card is refused at submit, and cancel or a crash touches only the worker running that job. On a backend no `cost` entry describes, a declared `vram_estimate` is held at admission to the pool's largest card, so a job the first card cannot hold is not refused when a larger card can hold it. | `tests/test_worker_pool.py::TestConcurrency::test_two_workers_run_two_jobs_at_once`, `tests/test_worker_pool.py::TestBackfill::test_a_job_too_big_for_the_free_card_waits_while_a_smaller_one_takes_it`, `tests/test_worker_pool.py::TestFitAtSubmit::test_a_job_too_big_for_every_card_is_refused_naming_the_largest`, `tests/test_worker_pool.py::TestCancel::test_cancel_reaches_the_worker_running_the_job`, `tests/test_worker_pool.py::TestCrashIsolation::test_a_crashed_worker_fails_only_its_own_job`, `tests/test_worker_pool.py::TestOomRanking::test_the_later_started_worker_is_ranked_first`, `tests/test_worker_pool.py::TestFitAtSubmit::test_a_job_only_the_larger_card_holds_is_admitted` | +| Worker pool dispatch | `dw/server/jobs.py`: `JobManager._next_dispatch`; `dw/server/pool.py`: `WorkerSlot`, `check_fits`, `largest_ceiling_gb`; `dw/vram_estimate.py`: `required_vram_gb`; `dw/server/admission.py`: `pool_capacity` | `--devices` runs one worker per card; a queued job goes to a free card that fits what it needs (none starts while a cache clear holds dispatch, `dw disk caches`), a job that fits no free card keeps its place while a smaller one behind it takes the card, a job bigger than every card is refused at submit, and cancel or a crash touches only the worker running that job. On a backend no `cost` entry describes, a declared `vram_estimate` is held at admission to the pool's largest card, so a job the first card cannot hold is not refused when a larger card can hold it. | `tests/test_worker_pool.py::TestConcurrency::test_two_workers_run_two_jobs_at_once`, `tests/test_worker_pool.py::TestBackfill::test_a_job_too_big_for_the_free_card_waits_while_a_smaller_one_takes_it`, `tests/test_worker_pool.py::TestFitAtSubmit::test_a_job_too_big_for_every_card_is_refused_naming_the_largest`, `tests/test_worker_pool.py::TestCancel::test_cancel_reaches_the_worker_running_the_job`, `tests/test_worker_pool.py::TestCrashIsolation::test_a_crashed_worker_fails_only_its_own_job`, `tests/test_worker_pool.py::TestOomRanking::test_the_later_started_worker_is_ranked_first`, `tests/test_worker_pool.py::TestFitAtSubmit::test_a_job_only_the_larger_card_holds_is_admitted` | | Card affinity | `dw/server/pool.py`: `choose_slot`; `dw/server/jobs.py`: `JobManager.route`; `dw/worker_protocol.py`: `workflow_identity` | Among the free cards a job fits it goes to the rerun's original card, else the card whose worker last ran its workflow identity (`workflow_identity`, the one rule the worker caches by too), else one with no worker running, else the first in `--devices` order; a cache probe and `plan.estimate` (`priced_for`) ask about the card `route` names, and memory is read and cleared per card. | `tests/test_worker_pool.py::TestIdentityAffinity::test_a_second_run_lands_on_the_card_that_ran_the_workflow_first`, `tests/test_worker_pool.py::TestRerunAffinity::test_a_rerun_lands_on_the_original_card_when_it_is_free`, `tests/test_worker_pool.py::TestProbeRouting::test_the_probe_goes_to_the_worker_the_job_would_be_routed_to`, `tests/test_worker_pool.py::TestMemoryPerCard::test_a_named_card_running_a_job_is_refused_though_the_other_is_idle` | | Per-card memory and cache | `dw/server/worker_memory.py`: `memory_status`, `clear_memory`, `probe_cache` | Each card keeps its own last memory reading; an idle card's worker is asked live, a busy one answers with the run's last report and says why it is not live, and a clear leaves a card running a job alone. Every request takes the card's lock with a bounded wait, and a card's lock is never taken while the JobManager's lock is held. | `tests/test_worker_pool.py::TestMemoryPerCard::test_a_named_card_running_a_job_is_refused_though_the_other_is_idle`, `tests/test_worker_pool.py::TestProbeRouting::test_the_probe_goes_to_the_worker_the_job_would_be_routed_to`, `tests/test_server.py::test_memory_says_why_a_reading_is_not_the_worker_s` | -| dw disk caches | `dw/disk_caches.py` | Members `inductor`, `triton`, `quantized`. Clear only a root carrying the `.dw-cache` marker, never while a job is running or queued (`quantized` also not during a model download); an entry delete takes only a 64-hex id, checked before any path is built; clear renames into `.clearing-*` then deletes. | `tests/test_disk_caches.py` | +| dw disk caches | `dw/disk_caches.py`; `dw/server/jobs.py`: `JobManager.hold_dispatch`; `dw/server/routes/system.py`: `clear_cache`, `delete_cache_entry` | Members `inductor`, `triton`, `quantized`. Clear only a root carrying the `.dw-cache` marker, never while a job is running or queued (`quantized` also not during a model download); an entry delete takes only a 64-hex id, checked before any path is built; clear renames into `.clearing-*` then deletes. The busy check and the dispatch hold are one step in `JobManager.hold_dispatch`, kept for the whole clear or entry delete: a job submitted meanwhile is accepted but stays queued until it ends. | `tests/test_disk_caches.py`, `tests/test_disk_caches.py::test_job_submitted_during_a_clear_waits_for_it` | | Job-to-run link | `dw/server/job_record.py`, `dw/server/jobs.py`: `JobManager.realized` | A job records `run_id`, `run_dir` and `run_version`, and `JobManager.realized` reads that run's `workflow.json`, confined to the output root. | `tests/test_server_jobs.py::test_realized_reads_the_file_the_run_wrote`, `tests/test_server_jobs.py::test_realized_refuses_a_run_dir_that_escapes_the_output_root` | | Job history | `dw/server/job_history.py`: `JobHistory` | Finished jobs persist in `jobs.sqlite`, in WAL mode, so the Jobs view survives a restart. | `tests/test_server.py::test_job_history_survives_restart_and_reruns`, `tests/test_job_history_wal.py::test_the_jobs_database_uses_wal_mode` | | HTTP security | `dw/server/http_security.py`: `install_middleware`, `query_token_ok` | Every API route needs the token, and only a route marked `query_token_ok` takes it as `?token=`. | `tests/test_security_auth.py::TestTheTokenGate::test_every_api_spelling_needs_the_token`, `tests/test_security_auth.py::TestTheTokenGate::test_the_query_token_is_refused_where_it_is_not_allowed` | From d70f124a3d8ae244ca3582639c4a5c7073d722c3 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 11 Oct 2026 19:44:51 -0500 Subject: [PATCH 07/29] feat(mcp): #847 - get_server_info(cache_entries=true) lists quantized-cache entries Co-Authored-By: Claude Sonnet 5.5 --- docs/MCP.md | 2 +- dw_mcp/tools_catalog.py | 6 +++-- dw_mcp/workspaces.py | 45 ++++++++++++++++++++++++++++-------- tests/test_mcp_workspaces.py | 24 +++++++++++++++++++ 4 files changed, 64 insertions(+), 13 deletions(-) diff --git a/docs/MCP.md b/docs/MCP.md index 2b6f884b..820c4831 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -229,7 +229,7 @@ when no single workflow covers it. | `list_models()` | — | List what the Hugging Face model cache holds, largest first | | `get_memory()` | `device` | Get VRAM and RAM statistics: `workers` has one reading per card (the top level repeats the first card's), and `device` (`cuda:1`) asks for that card alone. `gpu_*` is the card; `host_memory_rss_mb` / `host_memory_peak_rss_mb` are the worker process's resident and high-water host memory, beside the machine's `host_memory_total_mb` / `host_memory_available_mb`; on Linux `host_memory_rss_anon_mb` / `host_memory_rss_file_mb` split the resident figure into memory the worker owns and mapped-file pages (memory-mapped checkpoint weights) the kernel can drop, so a loader that maps rather than copies raises the resident and peak figures without raising committed memory (#709) - read both, since an offloading workflow keeps its weights in host RAM and the card says little about what it holds (a host field is absent, not null, where the platform cannot measure it). `host_pinned_reserved_mb` / `host_pinned_allocated_mb`, when present, are torch's pinned-host cache - the staging buffers group offloading moves weights through, part of `host_memory_rss_mb` and invisible in every `gpu_*` figure, which is what a worker holding GB after releasing every model is usually holding (#98). `live: true` means `info` was measured now and is the worker's own memory; only live readings are comparable with each other. `live: false` with `info: null` (and `stale: false`) means nothing has been measured because nothing is resident; `live: false` with a populated `info` is a cached earlier reading, with `reason` (`job_running`, `worker_stopped`, `worker_busy`, `worker_unreachable`) and `age_seconds` - one cached while a job loads a model understates what is resident, so ask again when the card is idle rather than comparing it with a live figure | | `get_health()` | — | Check that the server is alive, and which machine answered: `version`, `device`, whether any model process is resident (`worker_alive`), and the queue depth. The engine runs one job per GPU: `workers` has one entry per card - `device`, `name`, `vram_gb`, `alive`, its own `current_job`, and the worker's `host_memory_rss_mb` while it runs - and the top-level `current_job` is the longest-running job. `worker_alive: false` is the normal idle state on a server that has not run a job since startup or the last memory clear - not a fault - the on-demand worker starts with the next job (#206) | -| `get_server_info()` | — | What this installation can do and where it keeps things: `device` (the accelerator a run will use), `version`, the `workspace` this session is working in and the workflow/asset/output/prompt `directories` of *that* workspace, the bind address and port, whether a token is required, and whether MCP is mounted. Check the device before authoring - a CUDA-only choice (bitsandbytes, `torch.compile`, flash attention) is not available on an `mps` or `cpu` server. `runtime` (#222) reports the Python version, torch version and the CUDA version torch was built against, the NVIDIA driver version (when `nvidia-smi` is reachable), and the installed versions of diffusers, transformers, accelerate, bitsandbytes, peft, safetensors and sentencepiece (`null` for one not installed) - for diagnosing an environment mismatch between boxes without shelling in. `caches` lists dw's disk caches (`inductor`, `triton`, `quantized`), each with `name`, `bytes`, `files`, `enabled`, `clearable` and `disk_free`, plus `entry_count` for a cache with entries (no entry listing over MCP); it is omitted when the server has no `/api/caches`. There is no MCP tool to clear one - that is `DELETE /api/caches/{name}` or `.../entries/{entry}` ([SERVER.md](SERVER.md)) | +| `get_server_info()` | — | What this installation can do and where it keeps things: `device` (the accelerator a run will use), `version`, the `workspace` this session is working in and the workflow/asset/output/prompt `directories` of *that* workspace, the bind address and port, whether a token is required, and whether MCP is mounted. Check the device before authoring - a CUDA-only choice (bitsandbytes, `torch.compile`, flash attention) is not available on an `mps` or `cpu` server. `runtime` (#222) reports the Python version, torch version and the CUDA version torch was built against, the NVIDIA driver version (when `nvidia-smi` is reachable), and the installed versions of diffusers, transformers, accelerate, bitsandbytes, peft, safetensors and sentencepiece (`null` for one not installed) - for diagnosing an environment mismatch between boxes without shelling in. `caches` lists dw's disk caches (`inductor`, `triton`, `quantized`), each with `name`, `bytes`, `files`, `enabled`, `clearable` and `disk_free`, plus `entry_count` for a cache with entries, and `cache_entries=true` lists them (`id`, `model`, `subfolder`, `component`, `bytes`, `files`, `unreadable`, `created`, `last_hit`; the quantizer and dtype are not in `/api/caches`); it is omitted when the server has no `/api/caches`. There is no MCP tool to clear one - that is `DELETE /api/caches/{name}` or `.../entries/{entry}` ([SERVER.md](SERVER.md)) | | `list_jobs(limit=20, status=None, workspace=None)` | optional `limit` (newest N), `status` (one state or a comma-separated set of `queued`, `running`, `succeeded`, `failed`, `cancelled`), `workspace` | List queued, running and recent jobs, **newest first**. Bounded by default: the unbounded listing was over a client's tool-result limit on a server with a few months of history, which made it a tool that could not be called at all. `total` says how many matched and `truncated`/`next` say so when the answer was cut - raise `limit` or narrow with `status`. Without `workspace`, a named workspace lists its own jobs and the default one lists every job the server holds | | `list_gallery(limit=50, subfolder=None, only_orphans=False, workspace=None, folder=None, version=None)` | `limit`, `subfolder`, `only_orphans`, `workspace`, `folder`, `version` | List generated output files, newest first. A name is `//`, where `` may sit in the subfolder the step chose (`final/episode.mp4`); each entry carries `folder` (the workflow) and `subfolder` (by convention `final` or `intermediate`, `''` when the step chose none, any path the workflow wrote otherwise), and `subfolder="final"` lists only deliverables. Each entry carries `run_id` and `version` - that run's ordinal among the workflow's runs, which is how one of several runs that wrote the same basename is named to a person: the web UI labels the same file `v5`. The number is assigned when the run opens and never renumbered, so deleting a run leaves a gap rather than sliding the rest down (a failed run, or a rerun that reused every step, leaves one too - it took a number and may have nothing to list), and it is `null` under the flat output layout, which has no runs. `folder` with `version` lists that one run's files, and `output:/v5/` names one in a workflow; every other tool takes `name`. Each entry also carries a ready-made `url`, already scoped to the workspace that made it - a hand-built `/outputs/` URL 404s for anything but the default workspace. `only_orphans=True` inverts the call: instead of files, it returns run directories with no media anywhere under them (`runs`, each `{name, mtime}`) - a run whose output was deleted before `delete_output` could remove it by name, or one that failed before writing anything; `subfolder` does not apply in this mode, and `name` is exactly what `delete_output` accepts (#170). `workspace` names the workspace for this one call without switching the session to it - the same pin `run_workflow` takes, so a job run into another workspace stays reachable from the session that queued it | | `get_gallery_metadata(name, envelope=False, workspace=None, interval_seconds=None)` | `name`, `workspace` | Get the metadata embedded in a generated file — or, when `name` is an `asset:` reference, what an *input* asset holds (`source` says which; `job` is null for an asset). Reading an input's duration, frame count, fps and sample rate before a run is how a caller learns the `total_frames`, `fps` and `sample_rate` a workflow expects it to supply: the exact workflow and arguments that produced it, and, for audio/video, a `media` block (duration, rate, channels, fps, size, peak/mean dBFS) and `findings` - each level problem the server measured against `dw/audio_qc.py`'s thresholds (`full_scale`, `near_silent`), with the fix. Only an image (PNG/JPEG/WebP) embeds `metadata` this way — it is always null for audio and video, and `next` then points at `get_job_workflow(job_id)` when `job` is known, or says a kept asset carries no provenance at all when it isn't. `envelope=true` adds `media.envelope` — `rms_dbfs` and `peak_dbfs` one entry per second, or per `interval_seconds` (default 1.0, 0.05 to 3600; 0.1 places a vocal onset; a finer-than-default width that would need over 2400 bins is refused with the interval that fits) — which is what locates something in a track rather than measuring the whole of it. `media.shots` is set on an output joined from shots: one `{name, start_frame, num_frames, start_sample, num_samples}` per shot, as the join measured them (see the run manifest in docs/WORKFLOW_GUIDE.md), else null. `workspace` names the workspace for this one call without switching the session to it - the same pin `run_workflow` takes, so a job run into another workspace stays reachable from the session that queued it | diff --git a/dw_mcp/tools_catalog.py b/dw_mcp/tools_catalog.py index cb5e9d93..dfb3e2a7 100644 --- a/dw_mcp/tools_catalog.py +++ b/dw_mcp/tools_catalog.py @@ -214,6 +214,7 @@ def get_health( def get_server_info( self, + cache_entries: bool = False, ) -> dict: """Get what this installation can do and where it keeps things: the accelerator a run will use (`device` - cuda, mps or cpu), the dw @@ -227,8 +228,9 @@ def get_server_info( workflow is read under: false - the default - means the file is untrusted input, so an out-of-ecosystem import, remote code, and a media location outside the workspace's roots are all refused. - `caches` sizes dw's disk caches (cleared from the server page).""" - return workspaces.server_info(self.client) + `caches` sizes dw's disk caches; `cache_entries=True` lists the + quantized cache's entries (`model`, `subfolder`, `component`).""" + return workspaces.server_info(self.client, cache_entries) def list_jobs( self, limit: int = 20, status: str | None = None, workspace: str | None = None diff --git a/dw_mcp/workspaces.py b/dw_mcp/workspaces.py index 237d5509..7cf69cee 100644 --- a/dw_mcp/workspaces.py +++ b/dw_mcp/workspaces.py @@ -186,16 +186,17 @@ def delete_workspace(client, name, acknowledged_cost=False): return {**result, "current": client.workspace} -def server_info(client): +def server_info(client, cache_entries=False): """What this installation can do and where it keeps things: the accelerator, version, directories, and which workspace this session works - in. The directories are the session workspace's. + in. The directories are the session workspace's. `cache_entries` lists + each entry of a cache that has them (the quantized cache). """ # /api/server answers for the session's workspace (the client scopes # every request), so its directories are already this workspace's info = catalog.get_server_info(client) info["workspace"] = client.workspace - caches = _cache_sizes(client) + caches = _cache_sizes(client, cache_entries) if caches is not None: info["caches"] = caches return info @@ -207,7 +208,7 @@ def server_info(client): _CACHE_FIELDS = ("name", "bytes", "files", "enabled", "clearable", "disk_free") -def _cache_sizes(client): +def _cache_sizes(client, cache_entries=False): """dw's disk caches (#828), or None from a server without /api/caches - the rest of get_server_info doesn't depend on them.""" try: @@ -217,13 +218,37 @@ def _cache_sizes(client): caches = listing.get("caches") if isinstance(listing, dict) else None if not isinstance(caches, list): return None - return [_cache_summary(cache) for cache in caches if isinstance(cache, dict)] + return [ + _cache_summary(cache, cache_entries) + for cache in caches + if isinstance(cache, dict) + ] + + +# What `cache_entries` carries of each entry: which model component it holds +_ENTRY_FIELDS = ( + "id", + "model", + "subfolder", + "component", + "bytes", + "files", + "unreadable", + "created", + "last_hit", +) -def _cache_summary(cache): +def _cache_summary(cache, cache_entries=False): summary = {field: cache.get(field) for field in _CACHE_FIELDS} - # A cache with entries (quantized) is counted, not listed: the listing is - # GET /api/caches, for the page that deletes them - if isinstance(cache.get("entries"), list): - summary["entry_count"] = len(cache["entries"]) + # A cache with entries (quantized) is counted; `cache_entries` lists them + entries = cache.get("entries") + if isinstance(entries, list): + summary["entry_count"] = len(entries) + if cache_entries: + summary["entries"] = [ + {field: entry.get(field) for field in _ENTRY_FIELDS} + for entry in entries + if isinstance(entry, dict) + ] return summary diff --git a/tests/test_mcp_workspaces.py b/tests/test_mcp_workspaces.py index e6f7a74c..ceb3635b 100644 --- a/tests/test_mcp_workspaces.py +++ b/tests/test_mcp_workspaces.py @@ -324,6 +324,30 @@ def test_a_cache_with_entries_carries_only_their_count(self): assert quantized["entry_count"] == 2 assert "entries" not in quantized + def test_cache_entries_lists_each_entry_on_request(self): + entry = { + "id": "a" * 64, + "bytes": 100, + "files": 2, + "unreadable": False, + "model": "org/m", + "subfolder": "transformer", + "component": "FluxTransformer2DModel", + "created": 1.0, + "last_hit": None, + "extra": "dropped", + } + listing = {"caches": [{"name": "quantized", "bytes": 100, "entries": [entry]}]} + client = self.info_client(httpx.Response(200, json=listing)) + + [quantized] = server_info(client, cache_entries=True)["caches"] + + [listed] = quantized["entries"] + assert listed["component"] == "FluxTransformer2DModel" + assert listed["model"] == "org/m" + assert listed["subfolder"] == "transformer" + assert "extra" not in listed + @pytest.mark.parametrize("status", [404, 500]) def test_caches_omitted_when_the_listing_fails(self, status): client = self.info_client(httpx.Response(status, json={"detail": "no"})) From 080f104384c3396e2e0d8a052fcc6a802fb3eaa7 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 11 Oct 2026 19:45:25 -0500 Subject: [PATCH 08/29] fix(mcp): #847 - trim get_server_info docstring to the surface budget Co-Authored-By: Claude Sonnet 5.5 --- dw_mcp/tools_catalog.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/dw_mcp/tools_catalog.py b/dw_mcp/tools_catalog.py index dfb3e2a7..eceedfdf 100644 --- a/dw_mcp/tools_catalog.py +++ b/dw_mcp/tools_catalog.py @@ -219,15 +219,13 @@ def get_server_info( """Get what this installation can do and where it keeps things: the accelerator a run will use (`device` - cuda, mps or cpu), the dw version, and the workflow, output and prompt directories. Check the - device before authoring: a CUDA-only choice - bitsandbytes - quantization, torch.compile, flash attention - is not available on - an mps or cpu server, and `directories` is what a path passed to - run_workflow or download_output is relative to. If this session - works in a named workspace, `directories` are scoped to that - workspace. `trust_workflows` reports the posture a submitted - workflow is read under: false - the default - means the file is - untrusted input, so an out-of-ecosystem import, remote code, and a - media location outside the workspace's roots are all refused. + device before authoring: CUDA-only choices (bitsandbytes, + torch.compile, flash attention) are unavailable on mps or cpu, and `directories` is what a path passed to + run_workflow or download_output is relative to (scoped to this + session's workspace). `trust_workflows` false - the default - means + a submitted workflow is untrusted input: an out-of-ecosystem + import, remote code, and media outside the workspace's roots are + refused. `caches` sizes dw's disk caches; `cache_entries=True` lists the quantized cache's entries (`model`, `subfolder`, `component`).""" return workspaces.server_info(self.client, cache_entries) From 1a14524cc2626239dc20c49700c106659059330a Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 11 Oct 2026 20:33:48 -0500 Subject: [PATCH 09/29] fix(ui): a step whose scheduler sets only a shift opens in full view #147 let a scheduler or audio_scheduler block be just {"shift": ...}, but the full view read scheduler.configuration.scheduler_type unguarded. The throw left the compact digest on screen while the toggle said full - every H3 template's step. The scheduler section now takes either form, edits the shift, and shows audio_scheduler; the digest reads both as scheduler lines rather than listing audio_scheduler as a component. Co-Authored-By: Claude Opus 5.5 --- ui/src/lib/digest.test.ts | 19 +++++ ui/src/lib/digest.ts | 19 ++++- ui/src/lib/editor/PipelineOptions.svelte | 100 +++++++++++++++++------ ui/src/lib/editor/StepEditor.test.ts | 28 +++++++ 4 files changed, 140 insertions(+), 26 deletions(-) diff --git a/ui/src/lib/digest.test.ts b/ui/src/lib/digest.test.ts index 87e2c31c..7dbc1551 100644 --- a/ui/src/lib/digest.test.ts +++ b/ui/src/lib/digest.test.ts @@ -60,6 +60,25 @@ describe('stepDigest', () => { expect(d.lines.find((l) => l.section === 'arguments')).toBeUndefined() }) + it('reads a shift-only scheduler as a scheduler, not a component', () => { + const d = stepDigest({ + name: 'g', + pipeline: { + configuration: { component_type: 'ModularPipeline' }, + from_pretrained_arguments: { model_name: 'm' }, + scheduler: { shift: 'variable:video_shift' }, + audio_scheduler: { shift: 3 }, + }, + }) + const bySection = Object.fromEntries( + d.lines.map((l) => [l.section, l.text]), + ) + expect(bySection.components).toBeUndefined() + expect(bySection.scheduler).toBe( + 'shift variable:video_shift · audio: shift 3', + ) + }) + it('truncates long argument values', () => { const d = stepDigest({ name: 'g', diff --git a/ui/src/lib/digest.ts b/ui/src/lib/digest.ts index 479d3d85..43b6931b 100644 --- a/ui/src/lib/digest.ts +++ b/ui/src/lib/digest.ts @@ -22,6 +22,7 @@ const COMPONENT_KEYS = new Set([ 'arguments', 'loras', 'scheduler', + 'audio_scheduler', 'shared_components', 'reused_components', ]) @@ -120,9 +121,23 @@ function lorasLine(pipeline: Config): DigestLine[] { ] } +// A scheduler block names a replacement class, a shift, or both +function schedulerText(scheduler: Config | undefined): string { + return [ + scheduler?.configuration?.scheduler_type, + scheduler?.shift !== undefined ? `shift ${scheduler.shift}` : null, + ] + .filter(Boolean) + .join(' · ') +} + function schedulerLine(pipeline: Config): DigestLine[] { - const schedulerType = pipeline.scheduler?.configuration?.scheduler_type - return schedulerType ? [{ section: 'scheduler', text: schedulerType }] : [] + const video = schedulerText(pipeline.scheduler) + const audio = schedulerText(pipeline.audio_scheduler) + const text = [video, audio ? `audio: ${audio}` : null] + .filter(Boolean) + .join(' · ') + return text ? [{ section: 'scheduler', text }] : [] } function accelerationLine(configuration: Config): DigestLine[] { diff --git a/ui/src/lib/editor/PipelineOptions.svelte b/ui/src/lib/editor/PipelineOptions.svelte index 9e37e74a..c673f01f 100644 --- a/ui/src/lib/editor/PipelineOptions.svelte +++ b/ui/src/lib/editor/PipelineOptions.svelte @@ -54,6 +54,29 @@ } } + // A scheduler block is a replacement class, a shift on the pipeline's own + // scheduler, or both (the schema takes either) - H3 sets only a shift + type SchedulerKey = 'scheduler' | 'audio_scheduler' + + function setSchedulerType(key: SchedulerKey, value: string) { + const scheduler = pipeline[key] + if (!value) { + delete scheduler.configuration + } else if (scheduler.configuration) { + scheduler.configuration.scheduler_type = value + } else { + scheduler.configuration = { scheduler_type: value } + scheduler.from_config_args = scheduler.from_config_args ?? {} + } + } + + function setShift(key: SchedulerKey, raw: string) { + const value = raw.trim() + const parsed = Number(value) + if (!value) delete pipeline[key].shift + else pipeline[key].shift = Number.isNaN(parsed) ? value : parsed + } + function toggleCache(enabled: boolean) { if (enabled) { configuration.cache = configuration.cache ?? { @@ -122,7 +145,50 @@ -
+{#snippet schedulerFields(key: SchedulerKey)} + {@const scheduler = pipeline[key]} + +
+ scheduler.configuration?.scheduler_type ?? '', + (v) => setSchedulerType(key, v) + } + placeholder="the pipeline's own - e.g. FlowMatchEulerDiscreteScheduler" + /> + {#if key === 'scheduler' && compatibles.length} +
+ interchangeable with: {compatibles + .slice(0, 6) + .join(', ')}{compatibles.length > 6 ? ', …' : ''} +
+ {/if} +
+ + setShift(key, e.currentTarget.value)} + placeholder="number or variable:name" + /> + {#if scheduler.configuration && scheduler.from_config_args} + + + {/if} +{/snippet} + +
scheduler
@@ -134,29 +200,12 @@ onchange={(e) => toggleScheduler(e.currentTarget.checked)} /> {#if pipeline.scheduler} - -
- - {#if compatibles.length} -
- interchangeable with: {compatibles - .slice(0, 6) - .join(', ')}{compatibles.length > 6 ? ', …' : ''} -
- {/if} -
- - + {@render schedulerFields('scheduler')} + {/if} + {#if pipeline.audio_scheduler} + audio scheduler + + {@render schedulerFields('audio_scheduler')} {/if}
@@ -255,6 +304,9 @@ gap: 0.5rem; max-width: 300px; } + .subhead { + font-weight: 600; + } .grid2 { display: grid; grid-template-columns: 150px minmax(0, 1fr); diff --git a/ui/src/lib/editor/StepEditor.test.ts b/ui/src/lib/editor/StepEditor.test.ts index 91e2e9c8..c26f8b65 100644 --- a/ui/src/lib/editor/StepEditor.test.ts +++ b/ui/src/lib/editor/StepEditor.test.ts @@ -35,3 +35,31 @@ it('shows a dtype and a content type it does not list as selected', () => { .value, ).toBe('audio/x-flac') }) + +// H3 sets only a shift on the pipeline's own schedulers - no configuration +// block - and the full view used to throw on it, leaving the compact digest +it('opens full on a step whose schedulers set only a shift', () => { + const h3 = step() + Object.assign(h3.pipeline, { + scheduler: { shift: 'variable:video_shift' }, + audio_scheduler: { shift: 3 }, + }) + render(StepEditor, { + step: h3, + index: 0, + count: 1, + mode: 'full', + onremove: () => {}, + onmove: () => {}, + }) + expect((screen.getByLabelText('model') as HTMLInputElement).value).toBe( + 'org/model', + ) + expect( + (document.getElementById('schedulershift-0') as HTMLInputElement).value, + ).toBe('variable:video_shift') + expect( + (document.getElementById('audio_schedulershift-0') as HTMLInputElement) + .value, + ).toBe('3') +}) From 80bacbef68246a7062f49c4f7a8ec897f388665c Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 11 Oct 2026 21:01:02 -0500 Subject: [PATCH 10/29] fix(gallery): grids show a video as its first frame; MP4 outputs are faststart The gallery put a