mirror of
https://github.com/Niko1221/Strata.git
synced 2026-11-07 14:21:06 -10:00
A saved run config follows the update: paths into a removed Control, engine or DeepSeek version folder are pointed at the current one (#4891)
Setup writes absolute paths into the run config (cwd and --expert-profile into the Control bundle that ran it, the picture reader and library folders into
an engine package, the DeepSeek package). An update keeps the new Control/engine and the one before, so the second update after setup removed the folder
the config names: the model showed as not installed and a start failed ("Qwen breaks each time Strata is updated").
strata_control/cfg_paths.py is the one resolver: a string that names a file in components/control/<ver>, components/engine/<ver>-<kind> or
engine-dsv41/<ver>-<kind> whose version folder is gone is mapped to the same file in the active Control / the newest engine of its own kind. Paths the
person typed, packs, models, output paths (--expert-profile-save, log) and folders that still exist are left alone.
- profiles.read_run_config applies it in memory, so the doctor, the model list, the launch plan and the host see the current folders.
- runtime.start writes it once into every saved run config, one line in the host log, keeps a caller's expected source digest valid, and stops with the
file named when it exists in no current version.
Tests: tests/terminal/test_cfg_paths.py (real update_control twice, real runtime.start for the Terminal and the app, DeepSeek); 9 of 11 fail without the wiring.
This commit is contained in:
1 parent
2fb096b938
commit
d0d31a4191
4 files changed
+486
No files matched your search
@@ -0,0 +1,152 @@
|
||||
"""Paths in a saved run config that name a version folder of Strata's own components, and what to do when an update has removed that folder.
|
||||
|
||||
Setup writes absolute paths into the run config: ``cwd`` and ``--expert-profile`` into the Control bundle that ran it
|
||||
(``<components>/control/<version>/...``), the picture reader and library folders into an engine package
|
||||
(``<components>/engine/<version>-<platform>-<backend>/...``), the DeepSeek engine into ``engine-dsv41/<version>-.../``. An update puts the new version
|
||||
beside the old one and removes the versions older than the one before, so the second update after a model was set up deleted the folders its config
|
||||
names: the model showed as "not installed" and a start failed (#4891; "Qwen breaks each time Strata is updated").
|
||||
|
||||
One resolver, used by both sides:
|
||||
|
||||
* ``resolve_config(cfg)`` (pure; ``profiles.read_run_config`` calls it, so the doctor, the model list, the launch plan and the runtime host see the
|
||||
current folders without a file being written);
|
||||
* ``repair(configs)`` (runtime.start calls it for the profile being started and every saved profile): the same, written once into the file, one plain
|
||||
line for each file it changed.
|
||||
|
||||
Only paths in Strata's own folders are touched (``components/control``, ``components/engine``, ``engine-dsv41``), and only when the version folder they
|
||||
name is GONE; a path the person typed themselves, a model or pack folder, or a version folder that still exists is left exactly as it is. A path whose
|
||||
file is nowhere in the current version either is not guessed: ``unresolved`` names it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_SEP = r"[\\/]"
|
||||
_COMPONENT = re.compile(rf"^(?P<head>.*?{_SEP}components{_SEP})(?P<kind>control|engine){_SEP}(?P<ver>[^\\/]+)(?P<rest>(?:{_SEP}.*)?)$", re.S)
|
||||
_DSV41 = re.compile(rf"^(?P<head>.*?{_SEP}engine-dsv41{_SEP})(?P<ver>[^\\/]+)(?P<rest>(?:{_SEP}.*)?)$", re.S)
|
||||
_VERSION = re.compile(r"^\d+(?:\.\d+)+")
|
||||
SKIP_KEYS = ("log",) # paths the engine writes, not reads
|
||||
SKIP_FLAGS = ("--expert-profile-save",)
|
||||
|
||||
|
||||
def _key(name: str) -> tuple:
|
||||
head = str(name).split("-")[0]
|
||||
return tuple(int(x) for x in head.split(".") if x.isdigit()), str(name)
|
||||
|
||||
|
||||
def _kind_of(name: str) -> str:
|
||||
return str(name).split("-", 1)[1] if "-" in str(name) else ""
|
||||
|
||||
|
||||
def _newest(folder: Path, kind: str | None = None) -> Path | None:
|
||||
try:
|
||||
found = [d for d in folder.iterdir() if d.is_dir() and _VERSION.match(d.name) and (kind is None or _kind_of(d.name) == kind)]
|
||||
except OSError:
|
||||
return None
|
||||
return max(found, key=lambda d: _key(d.name), default=None)
|
||||
|
||||
|
||||
def _active_control(control: Path) -> Path | None:
|
||||
try:
|
||||
data = json.loads((control / "active.json").read_text(encoding="utf-8-sig"))
|
||||
folder = control / str(data["folder"])
|
||||
if folder.is_dir():
|
||||
return folder
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
pass
|
||||
return _newest(control)
|
||||
|
||||
|
||||
def _components() -> Path:
|
||||
from strata_control import engine_component as ec
|
||||
return ec.components_dir()
|
||||
|
||||
|
||||
def resolve_path(value: str) -> tuple[str, bool] | None:
|
||||
"""For a string that names a file in a version folder of Strata's own that is gone: (the same file in the current version, True) when it is there,
|
||||
(value, False) when it is nowhere; None when the string is not such a path or its version folder still exists (nothing to do)."""
|
||||
if not isinstance(value, str) or len(value) < 8 or "\n" in value:
|
||||
return None
|
||||
m = _COMPONENT.match(value)
|
||||
if m:
|
||||
old = Path(m["head"] + m["kind"] + os.sep + m["ver"])
|
||||
if old.exists() or not _VERSION.match(m["ver"]):
|
||||
return None
|
||||
root = _components() / m["kind"]
|
||||
current = _active_control(root) if m["kind"] == "control" else _newest(root, _kind_of(m["ver"]))
|
||||
rest = m["rest"].lstrip("\\/")
|
||||
else:
|
||||
m = _DSV41.match(value)
|
||||
if not m:
|
||||
return None
|
||||
old = Path(m["head"] + m["ver"])
|
||||
if old.exists() or not _VERSION.match(m["ver"]):
|
||||
return None
|
||||
current = _newest(Path(m["head"]), _kind_of(m["ver"]))
|
||||
rest = m["rest"].lstrip("\\/")
|
||||
if current is None:
|
||||
return value, False
|
||||
new = current / rest if rest else current
|
||||
return (str(new), True) if new.exists() else (value, False)
|
||||
|
||||
|
||||
def _walk(obj, found: list, lost: list, key: str = ""):
|
||||
if isinstance(obj, str):
|
||||
if key in SKIP_KEYS:
|
||||
return obj
|
||||
res = resolve_path(obj)
|
||||
if res is None:
|
||||
return obj
|
||||
(found if res[1] else lost).append((obj, res[0]))
|
||||
return res[0]
|
||||
if isinstance(obj, list):
|
||||
out, skip = [], False
|
||||
for item in obj:
|
||||
out.append(item if skip else _walk(item, found, lost, key))
|
||||
skip = isinstance(item, str) and item in SKIP_FLAGS
|
||||
return out
|
||||
if isinstance(obj, dict):
|
||||
return {k: _walk(v, found, lost, str(k)) for k, v in obj.items()}
|
||||
return obj
|
||||
|
||||
|
||||
def resolve_config(cfg: dict) -> tuple[dict, list, list]:
|
||||
"""(the config with every path of a removed version folder pointed at the current one, [(old, new)], [(old, "") that exist nowhere])."""
|
||||
found: list = []
|
||||
lost: list = []
|
||||
return _walk(cfg, found, lost), found, [(old, "") for old, _ in lost]
|
||||
|
||||
|
||||
def repair(configs) -> tuple[list[str], list[str]]:
|
||||
"""For a START: every run config in `configs` is resolved and, when a path changed, written once (atomically, nothing else changed).
|
||||
Returns (one plain line for each file changed, one plain line for each path that exists nowhere). Never raises."""
|
||||
notes: list[str] = []
|
||||
problems: list[str] = []
|
||||
seen = set()
|
||||
for c in configs:
|
||||
c = Path(c)
|
||||
if c in seen:
|
||||
continue
|
||||
seen.add(c)
|
||||
try:
|
||||
cfg = json.loads(c.read_text(encoding="utf-8-sig"))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if not isinstance(cfg, dict):
|
||||
continue
|
||||
new, found, lost = resolve_config(cfg)
|
||||
for old, _ in lost:
|
||||
problems.append(f"{c.name}: {old} was removed by an update and no file of that name is in the current version; run setup again for this model")
|
||||
if not found:
|
||||
continue
|
||||
try:
|
||||
from strata_control import profiles
|
||||
profiles._atomic(c, json.dumps(new, indent=2).encode("utf-8"))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
notes.append(f"{c.name}: {len(found)} path{'s' if len(found) != 1 else ''} named folders of an earlier Strata version that an update removed ("
|
||||
+ ", ".join(sorted({Path(o).name or o for o, _ in found})[:3]) + "); they now name the current version.")
|
||||
return notes, problems
|
||||
@@ -137,6 +137,9 @@ def read_run_config(path: Path) -> dict:
|
||||
cfg = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
except (OSError, ValueError) as exc:
|
||||
raise ProfileError(f"cannot read run config ({type(exc).__name__})", 5) from None
|
||||
if isinstance(cfg, dict): # paths into a version folder of Strata's own that an update removed: the current one
|
||||
from strata_control import cfg_paths
|
||||
cfg = cfg_paths.resolve_config(cfg)[0]
|
||||
if not isinstance(cfg, dict) or not isinstance(cfg.get("args"), list) or not all(
|
||||
isinstance(arg, str) for arg in cfg["args"]
|
||||
):
|
||||
|
||||
@@ -515,6 +515,36 @@ def _check_start_candidate(name: str, expected_profile_revision: str | None,
|
||||
return plan, digest
|
||||
|
||||
|
||||
def _repair_config_paths(name: str) -> tuple[str | None, str | None]:
|
||||
"""Before a start: every path of the saved run configs that names a version folder of Strata's own that an update removed (the Control bundle
|
||||
that ran setup: cwd, --expert-profile; an engine package: the picture reader, library folders; the DeepSeek package) is pointed at the current
|
||||
version and written once (strata_control/cfg_paths.py; the same resolver the config reader applies in memory). One line of what it did goes to
|
||||
the profile's host log. Returns (the profile's own run config digest before, after) when it changed that file, for a caller that planned the
|
||||
start on the old digest. A path that exists in no current version stops the start with the file named."""
|
||||
try:
|
||||
from strata_control import cfg_paths, dsv41_paths
|
||||
profile, _ = profiles.load(name)
|
||||
own = Path(profile["source_config"])
|
||||
before = hashlib.sha256(own.read_bytes()).hexdigest()
|
||||
configs = [own, *[c for c in dsv41_paths.saved_configs() if c != own]]
|
||||
notes, problems = cfg_paths.repair(configs)
|
||||
if notes:
|
||||
_, _, log_path = _paths(name)
|
||||
if log_path.parent.is_dir():
|
||||
with open(log_path, "ab") as stream:
|
||||
for note in notes:
|
||||
stream.write(f"{time.strftime('%Y-%m-%dT%H:%M:%S')} config: {note}\n".encode("utf-8"))
|
||||
mine = [p for p in problems if p.startswith(own.name + ":")]
|
||||
after = hashlib.sha256(own.read_bytes()).hexdigest()
|
||||
if mine:
|
||||
raise ProfileError(mine[0], 6)
|
||||
return (before, after) if after != before else (None, None)
|
||||
except ProfileError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - a start never fails because of this
|
||||
return None, None
|
||||
|
||||
|
||||
def _repair_dsv41_paths(name: str) -> None:
|
||||
"""Before a start: a saved profile that runs the DeepSeek engine from an engine-dsv41/ folder an update has replaced is pointed at the newest
|
||||
installed package of its own kind (every saved profile's run config, so the next start of another one is repaired too). Local only: it never
|
||||
@@ -566,6 +596,9 @@ def start(name: str, *, wait: float = 300, shutdown_timeout: float = 30,
|
||||
if expected_source_sha256 is not None and (not isinstance(expected_source_sha256, str) or
|
||||
not re.fullmatch(r"[0-9a-f]{64}", expected_source_sha256)):
|
||||
raise ProfileError("expected source digest must be 64 lowercase hex characters")
|
||||
changed_from, changed_to = _repair_config_paths(name)
|
||||
if changed_from is not None and expected_source_sha256 == changed_from:
|
||||
expected_source_sha256 = changed_to # (the caller planned on the file as it was; Strata rewrote it, the caller did not)
|
||||
_repair_dsv41_paths(name)
|
||||
plan, _ = _check_start_candidate(name, expected_profile_revision, expected_source_sha256)
|
||||
_engine_preflight(plan)
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
"""#4891 ("Qwen breaks each time Strata is updated"): a saved run config names folders of one version of Strata's own components (the Control bundle that
|
||||
ran setup: cwd, --expert-profile; an engine package: the picture reader, library folders; the DeepSeek package). The update keeps the new Control and
|
||||
the one before it, so the SECOND update after setup removes the folder the config names; the model then showed as "not installed" and a start failed.
|
||||
|
||||
The updates here are real (`updater.run_update(control=True)` unpacks and prunes Control folders; `prune_engines` / `dsv41_paths.prune` remove the
|
||||
older engine packages); the starts are real `runtime.start` calls with a fake engine, the way the app (with --die-with-pid) and the Terminal start.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from strata_control import cfg_paths, doctor, dsv41_paths, engine_component as ec, profiles, runtime, updater # noqa: E402
|
||||
from tests.terminal.test_updater import PLATFORM, UpdaterCase # noqa: E402
|
||||
|
||||
WINDOWS = os.name == "nt"
|
||||
FAKE_ENGINE = ("#!" + sys.executable + "\nimport sys\n"
|
||||
"print('READY 4096 stop', flush=True)\n"
|
||||
"for line in sys.stdin:\n"
|
||||
" if line.strip() == 'QUIT': break\n"
|
||||
" if line.startswith(('GEN ', 'BGEN ')):\n"
|
||||
" print('T 97', flush=True)\n"
|
||||
" print('DONE 1 1 1.0 1.0 length 0 0 0', flush=True)\n")
|
||||
|
||||
|
||||
class StaleConfigPaths(UpdaterCase):
|
||||
# (UpdaterCase's own tests are not repeated)
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.control = self.components / "control"
|
||||
self.engine_root = self.components / "engine"
|
||||
self.ds_root = self.root / "data" / "engine-dsv41"
|
||||
self.sequence = 0
|
||||
# Control 0.1.44 is what ran setup: its data/ holds the shipped expert profile
|
||||
self.install_control("0.1.44")
|
||||
(self.control / "active.json").write_text(json.dumps({"version": "0.1.44", "folder": "0.1.44", "sha256": "0" * 64}))
|
||||
# the engine package that setup (or an earlier update) wrote into the config, and the DeepSeek package
|
||||
self.engine_kind = f"{PLATFORM}-cuda-cuda13"
|
||||
(self.root / "packs" / "iq3_s").mkdir(parents=True)
|
||||
self.old_engine = self.make_engine_folder(self.engine_root, "0.1.44")
|
||||
self.old_ds = self.make_ds_folder("0.1.49")
|
||||
self.tokenizer = str(self.root / "tokenizer")
|
||||
self.mine = self.root / "mine" / "my-profile.bin" # a path the person typed themselves
|
||||
self.configs = {}
|
||||
|
||||
# ------------------------------------------------------------------ the world
|
||||
|
||||
def install_control(self, version):
|
||||
folder = self.control / version
|
||||
(folder / "data").mkdir(parents=True)
|
||||
(folder / "strata.py").write_text(f"print('strata {version} (python stub)')\n")
|
||||
(folder / "data" / "expert-profile.bin").write_bytes(b"profile-" + version.encode())
|
||||
return folder
|
||||
|
||||
def make_engine_folder(self, root, version, script_name=None):
|
||||
folder = root / f"{version}-{self.engine_kind}"
|
||||
folder.mkdir(parents=True)
|
||||
name = script_name or ("engine.cmd" if WINDOWS else "engine.py")
|
||||
script = folder / "engine.py"
|
||||
script.write_text(FAKE_ENGINE, encoding="utf-8")
|
||||
script.chmod(0o700)
|
||||
if WINDOWS:
|
||||
(folder / "engine.cmd").write_bytes(f'@echo off\r\n"{sys.executable}" "%~dp0engine.py" %*\r\n'.encode())
|
||||
(folder / "strata-vision").write_bytes(b"reader " + version.encode())
|
||||
return folder
|
||||
|
||||
def make_ds_folder(self, version):
|
||||
folder = self.ds_root / f"{version}-{self.engine_kind}"
|
||||
folder.mkdir(parents=True)
|
||||
(folder / dsv41_paths.exe_name()).write_bytes(b"deepseek " + version.encode())
|
||||
(folder / dsv41_paths.exe_name()).chmod(0o755)
|
||||
return folder
|
||||
|
||||
def engine_exe(self, folder):
|
||||
return folder / ("engine.cmd" if WINDOWS else "engine.py")
|
||||
|
||||
def publish_control(self, version):
|
||||
bundle = self.root / f"bundle-{version}"
|
||||
(bundle / "strata_control").mkdir(parents=True)
|
||||
(bundle / "data").mkdir()
|
||||
(bundle / "data" / "expert-profile.bin").write_bytes(b"profile-" + version.encode())
|
||||
(bundle / "strata.py").write_text(f"print('strata {version} (python stub)')\n")
|
||||
(bundle / "strata_control" / "__init__.py").write_text("")
|
||||
from feed_tool import build_feed, pack_control
|
||||
shutil.rmtree(self.release, ignore_errors=True)
|
||||
(self.release / "control").mkdir(parents=True)
|
||||
pack_control(bundle, self.release / "control", version=version, platform=PLATFORM)
|
||||
self.sequence += 1
|
||||
build_feed(self.release, key=self.key, sequence=self.sequence, control_dir=self.release / "control")
|
||||
|
||||
def update_to(self, version):
|
||||
self.publish_control(version)
|
||||
out = updater.run_update(self.ctx(), engine=False, control=True)
|
||||
self.assertEqual(out["results"][0]["status"], "updated", out)
|
||||
|
||||
def two_updates(self):
|
||||
"""Setup ran on Control 0.1.44. Two updates later (0.1.45, then 0.1.46) Control keeps 0.1.46 and 0.1.45, and the engine and DeepSeek packages
|
||||
are replaced the way their updates do it."""
|
||||
self.update_to("0.1.45")
|
||||
self.assertTrue((self.control / "0.1.44").is_dir(), "one update keeps the folder before it")
|
||||
self.update_to("0.1.46")
|
||||
self.assertFalse((self.control / "0.1.44").exists(), "the second update removes the folder setup wrote into the config")
|
||||
new_engine = self.make_engine_folder(self.engine_root, "0.1.46")
|
||||
updater.prune_engines(self.components, {new_engine.name})
|
||||
self.assertFalse(self.old_engine.exists())
|
||||
new_ds = self.make_ds_folder("0.1.50")
|
||||
with mock.patch.object(dsv41_paths, "in_use", return_value=False):
|
||||
dsv41_paths.prune(self.ds_root, new_ds / dsv41_paths.exe_name())
|
||||
self.assertFalse(self.old_ds.exists())
|
||||
return new_engine, new_ds
|
||||
|
||||
def qwen_config(self, name, vision=False, **extra):
|
||||
cfg = {"exe": str(self.engine_exe(self.old_engine)), "backend": "cuda", "tokenizer": self.tokenizer,
|
||||
"cwd": str(self.control / "0.1.44"), "model_name": "fake", "host": "127.0.0.1", "port": 0,
|
||||
"lib_dirs": [str(self.old_engine)],
|
||||
"args": ["--pack", str(self.root / "packs" / "iq3_s"),
|
||||
"--expert-profile", str(self.control / "0.1.44" / "data" / "expert-profile.bin"),
|
||||
"--expert-profile-save", str(self.control / "0.1.44" / "data" / "learned.bin"),
|
||||
"--mtp", "2", "--lookup-file", str(self.mine)]}
|
||||
if vision: # (a real start would run the picture reader: only the read-side tests ask for it)
|
||||
cfg["vision"] = {"exe": str(self.old_engine / "strata-vision")}
|
||||
cfg.update(extra)
|
||||
path = self.root / "run" / f"{name}.json"
|
||||
path.parent.mkdir(exist_ok=True)
|
||||
path.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
|
||||
profiles.create(name, str(path))
|
||||
self.configs[name] = path
|
||||
return path
|
||||
|
||||
def deepseek_config(self, name):
|
||||
exe = self.old_ds / dsv41_paths.exe_name()
|
||||
cfg = {"exe": str(exe), "arch": "deepseek41", "backend": "cuda", "tokenizer": self.tokenizer, "cwd": str(self.control / "0.1.44"),
|
||||
"model_name": "deepseek", "host": "127.0.0.1", "port": 0, "lib_dirs": [str(self.old_ds)],
|
||||
"args": ["--pack", str(self.root / "packs" / "ds"), "--max-context", "8192"]}
|
||||
path = self.root / "run" / f"{name}.json"
|
||||
path.parent.mkdir(exist_ok=True)
|
||||
path.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
|
||||
profiles.create(name, str(path))
|
||||
self.configs[name] = path
|
||||
return path
|
||||
|
||||
def cfg_of(self, path):
|
||||
return json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
|
||||
def without_output_path(self, path):
|
||||
"""The config's text without the one path the engine WRITES (--expert-profile-save), which is deliberately not looked for."""
|
||||
return Path(path).read_text(encoding="utf-8").replace(json.dumps(str(self.control / "0.1.44" / "data" / "learned.bin"))[1:-1], "")
|
||||
|
||||
def host_log_lines(self, name, word="config:"):
|
||||
_, _, log_path = runtime._paths(name)
|
||||
return [l for l in log_path.read_text(encoding="utf-8").splitlines() if word in l] if log_path.exists() else []
|
||||
|
||||
def stop(self, name):
|
||||
try:
|
||||
if runtime._read_state(name) is not None:
|
||||
runtime.stop(name, timeout=12)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------ what an update does
|
||||
|
||||
def test_the_folders_the_config_names_are_gone_after_two_updates_and_the_model_looks_missing(self):
|
||||
path = self.qwen_config("qwen", vision=True)
|
||||
self.two_updates()
|
||||
cfg = self.cfg_of(path)
|
||||
self.assertFalse(Path(cfg["cwd"]).exists())
|
||||
self.assertFalse(Path(cfg["args"][cfg["args"].index("--expert-profile") + 1]).exists())
|
||||
report = doctor.inspect(path)
|
||||
stale = [c for c in report["components"] if c["status"] != "ok" and c["name"] in ("expert-profile", "vision/exe", "engine")]
|
||||
self.assertEqual(stale, [], "the doctor (and so the model list) must follow the current folders: " + json.dumps(stale))
|
||||
|
||||
def test_reading_the_config_follows_the_current_version_in_memory_and_writes_nothing(self):
|
||||
path = self.qwen_config("qwen", vision=True)
|
||||
new_engine, _ = self.two_updates()
|
||||
before = path.read_bytes()
|
||||
cfg = profiles.read_run_config(path)
|
||||
active = self.control / "0.1.46"
|
||||
self.assertEqual(Path(cfg["cwd"]), active)
|
||||
self.assertEqual(Path(cfg["args"][cfg["args"].index("--expert-profile") + 1]), active / "data" / "expert-profile.bin")
|
||||
self.assertEqual(Path(cfg["vision"]["exe"]), new_engine / "strata-vision")
|
||||
self.assertEqual([Path(d) for d in cfg["lib_dirs"]], [new_engine])
|
||||
self.assertEqual(Path(cfg["exe"]), self.engine_exe(new_engine))
|
||||
self.assertEqual(path.read_bytes(), before)
|
||||
|
||||
def test_the_paths_the_person_typed_and_the_data_are_left_alone(self):
|
||||
path = self.qwen_config("qwen")
|
||||
self.two_updates()
|
||||
cfg = profiles.read_run_config(path)
|
||||
args = cfg["args"]
|
||||
self.assertEqual(args[args.index("--pack") + 1], str(self.root / "packs" / "iq3_s"))
|
||||
self.assertEqual(args[args.index("--lookup-file") + 1], str(self.mine)) # (typed, outside Strata's folders, does not exist: not touched)
|
||||
self.assertEqual(args[args.index("--mtp") + 1], "2")
|
||||
self.assertEqual(args[args.index("--expert-profile-save") + 1], str(self.control / "0.1.44" / "data" / "learned.bin"),
|
||||
"a path the engine writes is not a file to look for")
|
||||
self.assertEqual(cfg["tokenizer"], self.tokenizer)
|
||||
self.assertEqual(cfg["model_name"], "fake")
|
||||
|
||||
def test_a_folder_that_still_exists_is_not_replaced_by_a_newer_one(self):
|
||||
path = self.qwen_config("qwen")
|
||||
self.update_to("0.1.45") # 0.1.44 is still there (the version before the newest)
|
||||
before = path.read_bytes()
|
||||
cfg = profiles.read_run_config(path)
|
||||
self.assertEqual(cfg["cwd"], str(self.control / "0.1.44"))
|
||||
self.assertEqual(cfg_paths.repair([path]), ([], []))
|
||||
self.assertEqual(path.read_bytes(), before)
|
||||
|
||||
# ------------------------------------------------------------------ starts
|
||||
|
||||
def test_a_terminal_start_rewrites_the_config_once_and_says_so_in_one_line(self):
|
||||
path = self.qwen_config("term")
|
||||
new_engine, _ = self.two_updates()
|
||||
self.addCleanup(self.stop, "term")
|
||||
started = runtime.start("term", wait=15)
|
||||
self.assertTrue(started["ok"], started)
|
||||
cfg = self.cfg_of(path)
|
||||
self.assertEqual(cfg["cwd"], str(self.control / "0.1.46"))
|
||||
self.assertEqual(cfg["exe"], str(self.engine_exe(new_engine)))
|
||||
self.assertNotIn("0.1.44", self.without_output_path(path))
|
||||
self.assertEqual(len(self.host_log_lines("term")), 1, self.host_log_lines("term"))
|
||||
self.assertIn("earlier Strata version", self.host_log_lines("term")[0])
|
||||
runtime.stop("term", timeout=12)
|
||||
again = runtime.start("term", wait=15)
|
||||
self.assertTrue(again["ok"], again)
|
||||
self.assertEqual(len(self.host_log_lines("term")), 1, "nothing is rewritten the second time")
|
||||
|
||||
def test_an_app_start_with_die_with_pid_does_the_same(self):
|
||||
path = self.qwen_config("app-1")
|
||||
self.two_updates()
|
||||
self.addCleanup(self.stop, "app-1")
|
||||
started = runtime.start("app-1", wait=15, die_with_pid=os.getpid())
|
||||
self.assertTrue(started["ok"], started)
|
||||
self.assertEqual(self.cfg_of(path)["cwd"], str(self.control / "0.1.46"))
|
||||
|
||||
def test_a_start_planned_on_the_old_file_is_not_refused_because_strata_rewrote_it(self):
|
||||
import hashlib
|
||||
path = self.qwen_config("app-2")
|
||||
self.two_updates()
|
||||
self.addCleanup(self.stop, "app-2")
|
||||
planned = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
started = runtime.start("app-2", wait=15, expected_source_sha256=planned)
|
||||
self.assertTrue(started["ok"], started)
|
||||
|
||||
def test_every_saved_profile_is_repaired_by_one_start(self):
|
||||
a, b = self.qwen_config("one"), self.qwen_config("two")
|
||||
self.two_updates()
|
||||
self.addCleanup(self.stop, "one")
|
||||
self.assertTrue(runtime.start("one", wait=15)["ok"])
|
||||
self.assertNotIn("0.1.44", self.without_output_path(b), "the Models page reads the other profiles' configs too")
|
||||
|
||||
def test_deepseek_cwd_exe_and_libraries_follow_the_update_too(self):
|
||||
path = self.deepseek_config("ds")
|
||||
_, new_ds = self.two_updates()
|
||||
report = doctor.inspect(path)
|
||||
self.assertEqual([c for c in report["components"] if c["name"] == "engine" and c["status"] != "ok"], [])
|
||||
notes, problems = cfg_paths.repair([path])
|
||||
self.assertEqual(problems, [])
|
||||
self.assertEqual(len(notes), 1)
|
||||
cfg = self.cfg_of(path)
|
||||
self.assertEqual(cfg["exe"], str(new_ds / dsv41_paths.exe_name()))
|
||||
self.assertEqual(cfg["lib_dirs"], [str(new_ds)])
|
||||
self.assertEqual(cfg["cwd"], str(self.control / "0.1.46"))
|
||||
self.assertEqual(cfg["args"], ["--pack", str(self.root / "packs" / "ds"), "--max-context", "8192"])
|
||||
|
||||
def test_deepseek_start_goes_through_the_same_repair(self):
|
||||
path = self.deepseek_config("ds-app")
|
||||
self.two_updates()
|
||||
with mock.patch.object(runtime, "_check_start_candidate", side_effect=RuntimeError("stop after the repair")), \
|
||||
mock.patch.object(runtime, "_engine_preflight"):
|
||||
with self.assertRaises(RuntimeError):
|
||||
runtime.start("ds-app", wait=1, die_with_pid=os.getpid())
|
||||
cfg = self.cfg_of(path)
|
||||
self.assertIn("0.1.50", cfg["exe"])
|
||||
self.assertEqual(cfg["cwd"], str(self.control / "0.1.46"))
|
||||
|
||||
# ------------------------------------------------------------------ a file that is nowhere
|
||||
|
||||
def test_a_file_the_new_version_no_longer_has_stops_the_start_with_its_name(self):
|
||||
path = self.qwen_config("lost")
|
||||
self.two_updates()
|
||||
(self.control / "0.1.46" / "data" / "expert-profile.bin").unlink()
|
||||
(self.control / "0.1.45" / "data" / "expert-profile.bin").unlink()
|
||||
with self.assertRaises(profiles.ProfileError) as caught:
|
||||
runtime.start("lost", wait=5)
|
||||
text = str(caught.exception)
|
||||
self.assertIn("expert-profile.bin", text)
|
||||
self.assertIn("removed by an update", text)
|
||||
self.assertIn("run setup again", text)
|
||||
self.assertIn("0.1.44", path.read_text(encoding="utf-8"), "nothing is guessed: the file is left as it was")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user