diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ec63671aa..b271dbab3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,21 +20,21 @@ Linux is the primary development platform. macOS is supported. Source-checkout i Fedora/RHEL: ```bash -sudo dnf install python3 git ripgrep ffmpeg minisign pipewire gstreamer1-plugins-base gstreamer1-plugin-pipewire pulseaudio-utils +sudo dnf install python3 git ripgrep ffmpeg minisign libgomp pipewire gstreamer1-plugins-base gstreamer1-plugin-pipewire pulseaudio-utils curl -LsSf https://astral.sh/uv/install.sh | sh ``` Ubuntu/Debian: ```bash -sudo apt install python3 git ripgrep ffmpeg minisign pipewire gstreamer1.0-tools gstreamer1.0-pipewire pulseaudio-utils +sudo apt install python3 git ripgrep ffmpeg minisign libgomp1 pipewire gstreamer1.0-tools gstreamer1.0-pipewire pulseaudio-utils curl -LsSf https://astral.sh/uv/install.sh | sh ``` Arch: ```bash -sudo pacman -S python git ripgrep ffmpeg minisign pipewire gstreamer gst-plugin-pipewire libpulse +sudo pacman -S python git ripgrep ffmpeg minisign libgomp pipewire gstreamer gst-plugin-pipewire libpulse curl -LsSf https://astral.sh/uv/install.sh | sh ``` diff --git a/INSTALL.md b/INSTALL.md index cd51dd9a7..d6cfc65da 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -19,7 +19,15 @@ if solstone is running and healthy, skip to [install sol on your devices](#insta ### prerequisites -linux: install `uv` (`curl -LsSf https://astral.sh/uv/install.sh | sh`) and `ripgrep` (`rg`) from your distro package manager. +linux: install `uv` (`curl -LsSf https://astral.sh/uv/install.sh | sh`), +`ripgrep` (`rg`), and the system OpenMP runtime used by the default local +Parakeet transcription provider: + +```bash +sudo apt install libgomp1 # Ubuntu/Debian +sudo dnf install libgomp # Fedora/RHEL +sudo pacman -S libgomp # Arch +``` macOS: install xcode command line tools (`xcode-select --install`) and homebrew (https://brew.sh), then `brew install uv ripgrep`. @@ -74,7 +82,13 @@ this runs the setup readiness doctor battery, confirms the journal directory at let your human know: **open http://localhost:5015 in a browser**. the first-run wizard walks them through setting their identity and choosing how sol thinks — local by default (the bundled model runs right in the journal), or their own provider key if the machine can't run a local model. -a `solstone-journal` install bundles everything a journal host needs — PDF rendering, whisper, and the default CPU transcription stack are all included; `journal setup` downloads the transcription model. there are no separate à-la-carte extras to add. if the readiness doctor step (`journal doctor --readiness`) finds missing system libraries, it will tell you the exact install command to run for your platform. +a `solstone-journal` install bundles the Python and native artifacts a journal +host needs — PDF rendering, whisper, and the default CPU transcription stack +are included; `journal setup` downloads the transcription model. on Linux, the +host supplies the small system OpenMP runtime listed in prerequisites. +`journal doctor --readiness` runs the actual Parakeet binary before reporting +it ready and gives the exact package-manager command when that runtime is +missing. Pick one of `solstone-journal` or `solstone-journal-cuda` — the CPU and GPU ONNX runtimes share the same files and must not both be installed. `journal doctor` reports whether the transcription runtime, native speaker-analysis helper, and bundled models are ready. diff --git a/docs/DOCTOR.md b/docs/DOCTOR.md index a32224498..6ea8ea158 100644 --- a/docs/DOCTOR.md +++ b/docs/DOCTOR.md @@ -60,6 +60,7 @@ Use the diagnostic command that matches the question: | `journal_sync` | blocker | Concurrent-writer conflict check. | | `stale_alias_symlink` | blocker | Checks only the `journal` wrapper; stale aliases warn, never block, and `journal setup` repairs them. | | `launchd_stale_plist` | advisory | macOS only; stale legacy service plists should be removed with `journal service uninstall`, then repaired with `journal service install` only on a confirmed headless host. | +| `default_stt_ready` / `parakeet_cpp_stt_ready` | advisory | Linux Parakeet artifacts, binary loader readiness, model, and running server. A missing `libgomp.so.1` is reported as “OpenMP runtime unavailable” with the distro install command, before the supervisor can collapse it to a generic process exit. | | `feature:pdf-import`, `feature:pdf-export`, `feature:whisper` | advisory | Optional extras with exact install commands. | `host_dependencies` fix guidance is: Reinstall the journal host stack: @@ -73,6 +74,12 @@ are blocker failures. An installed service with no supervisor socket is a warning when the OS unit is not failed. Host dependency and feature checks report missing journal-host packaging pieces directly. +On Linux, Parakeet uses the host's GCC OpenMP runtime. Install it with +`sudo apt install libgomp1` on Ubuntu/Debian, `sudo dnf install libgomp` on +Fedora/RHEL, or `sudo pacman -S libgomp` on Arch. The readiness check executes +the pinned CPU binary, so file presence and executable bits alone cannot +produce a false-ready result. + On macOS, `supervisor_conflict` fails when `journal.app` is running while the legacy `org.solpbc.solstone` LaunchAgent is installed or loaded, or when a foreign persistent LaunchAgent targets `/Applications/solstone.app`. The proven diff --git a/solstone/think/doctor.py b/solstone/think/doctor.py index 9a95d5879..b37f04199 100644 --- a/solstone/think/doctor.py +++ b/solstone/think/doctor.py @@ -1173,9 +1173,24 @@ def _parakeet_cpp_ready_result(check: Check) -> CheckResult: except RuntimeError as exc: return make_result(check, "warn", str(exc), _PARAKEET_CPP_INSTALL_FIX) try: - parakeet_readiness.check_parakeet_cpp_files(cache_root, artifact_key) + paths = parakeet_readiness.check_parakeet_cpp_files(cache_root, artifact_key) except RuntimeError as exc: return make_result(check, "warn", str(exc), _PARAKEET_CPP_INSTALL_FIX) + binary_probe = parakeet_readiness.probe_parakeet_cpp_binary(paths["binary_cpu"]) + if not binary_probe.runnable: + if binary_probe.reason_code == parakeet_readiness.OPENMP_RUNTIME_UNAVAILABLE: + return make_result( + check, + "warn", + "parakeet-cpp cannot start: OpenMP runtime unavailable (libgomp.so.1)", + parakeet_readiness.openmp_runtime_install_guidance(), + ) + return make_result( + check, + "warn", + "parakeet-cpp binary cannot start", + _PARAKEET_CPP_INSTALL_FIX, + ) from solstone.think.providers import parakeet_server state, error = parakeet_server.probe_state() diff --git a/solstone/think/parakeet_readiness.py b/solstone/think/parakeet_readiness.py index 229c22b40..47b2a765e 100644 --- a/solstone/think/parakeet_readiness.py +++ b/solstone/think/parakeet_readiness.py @@ -14,7 +14,9 @@ from __future__ import annotations import json import os import platform +import subprocess import sys +from dataclasses import dataclass from pathlib import Path from typing import Any @@ -36,6 +38,16 @@ PARAKEET_CPP_BINARY_BACKENDS = ("cpu", "vulkan") PARAKEET_CPP_MODEL_REPO = "mudler/parakeet-cpp-gguf" PARAKEET_CPP_MODEL_FILENAME = "tdt-0.6b-v3-q8_0.gguf" PARAKEET_CPP_MODEL_REVISION = "bf0af9f425fa01809cadec671b3cb672709d13e9" +PARAKEET_CPP_PROBE_TIMEOUT_SECONDS = 10 +OPENMP_RUNTIME_UNAVAILABLE = "openmp_runtime_unavailable" +BINARY_NOT_RUNNABLE = "binary_not_runnable" + + +@dataclass(frozen=True) +class ParakeetCppProbe: + runnable: bool + reason_code: str + detail: str | None def _platform_info() -> tuple[str, str]: @@ -112,6 +124,71 @@ def check_parakeet_cpp_files(cache_root: Path, artifact_key: str) -> dict[str, P return paths +def probe_parakeet_cpp_binary(binary_path: Path) -> ParakeetCppProbe: + """Run the pinned binary so dynamic-loader failures become readiness facts.""" + try: + completed = subprocess.run( + [str(binary_path), "--version"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=PARAKEET_CPP_PROBE_TIMEOUT_SECONDS, + check=False, + ) + except subprocess.TimeoutExpired: + return ParakeetCppProbe( + runnable=False, + reason_code=BINARY_NOT_RUNNABLE, + detail=f"timed out after {PARAKEET_CPP_PROBE_TIMEOUT_SECONDS}s", + ) + except OSError as exc: + return ParakeetCppProbe( + runnable=False, + reason_code=BINARY_NOT_RUNNABLE, + detail=str(exc), + ) + + if completed.returncode == 0: + return ParakeetCppProbe(runnable=True, reason_code="ready", detail=None) + + detail = ( + (completed.stderr or "").strip() + or (completed.stdout or "").strip() + or f"exited with status {completed.returncode}" + ) + reason_code = ( + OPENMP_RUNTIME_UNAVAILABLE if "libgomp.so.1" in detail else BINARY_NOT_RUNNABLE + ) + return ParakeetCppProbe( + runnable=False, + reason_code=reason_code, + detail=detail, + ) + + +def openmp_runtime_install_guidance() -> str: + """Return verified distro guidance without making package install a side effect.""" + try: + release = platform.freedesktop_os_release() + except OSError: + release = {} + distro_ids = { + str(release.get("ID", "")).lower(), + *str(release.get("ID_LIKE", "")).lower().split(), + } + if distro_ids & {"debian", "ubuntu"}: + return "install the OpenMP runtime with: sudo apt install libgomp1" + if distro_ids & {"fedora", "rhel", "centos"}: + return "install the OpenMP runtime with: sudo dnf install libgomp" + if "arch" in distro_ids: + return "install the OpenMP runtime with: sudo pacman -S libgomp" + return ( + "install the system OpenMP runtime that provides libgomp.so.1, " + "then rerun journal doctor" + ) + + def _sentinel_path(variant: str) -> Path: if variant != "coreml": raise RuntimeError(f"parakeet sentinel is unsupported for variant {variant!r}") diff --git a/solstone/think/providers/parakeet_install.py b/solstone/think/providers/parakeet_install.py index 71df2af91..96d7de440 100644 --- a/solstone/think/providers/parakeet_install.py +++ b/solstone/think/providers/parakeet_install.py @@ -48,7 +48,6 @@ from solstone.think.utils import get_journal LOG = logging.getLogger(__name__) PARAKEET_PROVIDER_NAME = "parakeet" -_PROBE_TIMEOUT_SECONDS = 10 PARAKEET_SERVER_PINS: dict[tuple[str, str], dict[str, str]] = { ("x86_64-unknown-linux-gnu", "vulkan"): { @@ -410,33 +409,6 @@ def _chmod_executable(path: Path) -> None: path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) -def probe_binary_runnable(binary_path: str | Path) -> tuple[bool, str | None]: - import subprocess - - try: - completed = subprocess.run( - [str(binary_path), "--version"], - capture_output=True, - text=True, - timeout=_PROBE_TIMEOUT_SECONDS, - check=False, - ) - except subprocess.TimeoutExpired: - return False, f"timed out after {_PROBE_TIMEOUT_SECONDS}s" - except Exception as exc: - return False, str(exc) - - if completed.returncode == 0: - return True, None - - detail = ( - (completed.stderr or "").strip() - or (completed.stdout or "").strip() - or f"exited with status {completed.returncode}" - ) - return False, detail - - def _install_parakeet_server_unlocked( backend: str, journal_path: str | Path | None = None, @@ -749,6 +721,19 @@ def inspect_readiness(journal_path: str | Path | None = None) -> ReadinessOutcom binary_status, binary_reason_code = _combined_artifact_status( cpu_payload, vulkan_payload ) + runtime_probe = None + host: dict[str, Any] = {} + if readiness_status == "ready": + runtime_probe = parakeet_readiness.probe_parakeet_cpp_binary(cpu_path) + host["binary_runtime"] = { + "backend": "cpu", + "runnable": runtime_probe.runnable, + "reason_code": runtime_probe.reason_code, + "detail": runtime_probe.detail, + } + if not runtime_probe.runnable: + readiness_status = "host-ineligible" + reason_code = runtime_probe.reason_code return ReadinessOutcome( provider=PARAKEET_PROVIDER_NAME, status=readiness_status, # type: ignore[arg-type] @@ -767,11 +752,12 @@ def inspect_readiness(journal_path: str | Path | None = None) -> ReadinessOutcom "last_transition_at": status["last_transition_at"], "last_progress_at": status["last_progress_at"], }, - host={}, + host=host, artifacts={ "binary_installed": cpu_proof.ready and vulkan_proof.ready, "binary_cpu_installed": cpu_proof.ready, "binary_vulkan_installed": vulkan_proof.ready, + "binary_runnable": runtime_probe.runnable if runtime_probe else False, "model_installed": model_proof.ready, "binary_path_cpu": str(cpu_path), "binary_path_vulkan": str(vulkan_path), @@ -803,6 +789,11 @@ def ensure_artifacts_installed( ) if not readiness.artifacts["model_installed"]: raise ParakeetProviderError("model_missing", "Parakeet model is not installed.") + if not readiness.artifacts["binary_runnable"]: + raise ParakeetProviderError( + str(readiness.reason_code), + "Parakeet server cannot start on this host.", + ) return Path(readiness.artifacts[f"binary_path_{backend}"]), Path( readiness.artifacts["model_path"] ) @@ -826,5 +817,4 @@ __all__ = [ "model_dir", "model_path", "parakeet_server_artifact_key", - "probe_binary_runnable", ] diff --git a/solstone/think/providers/runtime_health.py b/solstone/think/providers/runtime_health.py index a95aba22e..86ed96fec 100644 --- a/solstone/think/providers/runtime_health.py +++ b/solstone/think/providers/runtime_health.py @@ -54,6 +54,7 @@ ReasonCode = Literal[ "host-admission-blocked", "platform-unsupported", "package-unavailable", + "openmp-runtime-unavailable", "ram-insufficient", "gpu-probe-failed", "gpu-unavailable", diff --git a/solstone/think/supervisor.py b/solstone/think/supervisor.py index b66625ef0..e63a4239c 100644 --- a/solstone/think/supervisor.py +++ b/solstone/think/supervisor.py @@ -2265,6 +2265,8 @@ def parakeet_physical_thread_count() -> int: _HOST_READINESS_REASON_MAP: dict[str, ReasonCode] = { "platform_unsupported": "platform-unsupported", "package_unavailable": "package-unavailable", + "binary_not_runnable": "package-unavailable", + "openmp_runtime_unavailable": "openmp-runtime-unavailable", "ram_insufficient": "ram-insufficient", "gpu_probe_failed": "gpu-probe-failed", "gpu_unavailable": "gpu-unavailable", diff --git a/tests/test_doctor.py b/tests/test_doctor.py index a042ab978..aae243ba3 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -457,6 +457,57 @@ class TestParakeetCppSttReady: assert result.detail == "parakeet-server not reachable: no port" assert result.fix == doctor._PARAKEET_CPP_START_FIX + def test_warns_with_openmp_fix_before_server_probe( + self, doctor, monkeypatch, tmp_path + ): + from solstone.think.providers import parakeet_server + + journal = tmp_path / "journal" + artifact_key = "x86_64-unknown-linux-gnu" + cache_root = doctor.parakeet_readiness.parakeet_cpp_cache_root(journal) + self.make_ready_files(doctor, cache_root, artifact_key) + monkeypatch.setattr( + doctor, "_resolve_configured_backend", lambda: "parakeet-cpp" + ) + monkeypatch.setattr( + doctor.parakeet_readiness, + "_platform_info", + lambda: ("linux", "x86_64"), + ) + monkeypatch.setattr(doctor, "get_journal_info", lambda: (str(journal), "env")) + monkeypatch.setattr( + doctor.parakeet_readiness, + "probe_parakeet_cpp_binary", + lambda _path: doctor.parakeet_readiness.ParakeetCppProbe( + runnable=False, + reason_code="openmp_runtime_unavailable", + detail=( + "error while loading shared libraries: libgomp.so.1: " + "cannot open shared object file" + ), + ), + ) + monkeypatch.setattr( + doctor.parakeet_readiness.platform, + "freedesktop_os_release", + lambda: {"ID": "fedora"}, + ) + monkeypatch.setattr( + parakeet_server, + "probe_state", + lambda: pytest.fail("server probe must follow binary readiness"), + ) + + result = doctor.parakeet_cpp_stt_ready_check(args(doctor)) + + assert result.status == "warn" + assert result.detail == ( + "parakeet-cpp cannot start: OpenMP runtime unavailable (libgomp.so.1)" + ) + assert result.fix == ( + "install the OpenMP runtime with: sudo dnf install libgomp" + ) + def test_ok_when_files_present_and_server_ready( self, doctor, monkeypatch, tmp_path ): diff --git a/tests/test_parakeet_install.py b/tests/test_parakeet_install.py index b60f8c5b8..996855256 100644 --- a/tests/test_parakeet_install.py +++ b/tests/test_parakeet_install.py @@ -66,8 +66,9 @@ def _server_tarball(tmp_path: Path, backend: str) -> Path: ) fixture_root = tmp_path / f"fixture-{backend}" / inner_name fixture_root.mkdir(parents=True) - (fixture_root / parakeet_readiness.PARAKEET_CPP_BINARY_NAME).write_bytes( - f"fake {parakeet_readiness.PARAKEET_CPP_BINARY_NAME} {backend}".encode() + (fixture_root / parakeet_readiness.PARAKEET_CPP_BINARY_NAME).write_text( + f"#!/bin/sh\nprintf 'fake {parakeet_readiness.PARAKEET_CPP_BINARY_NAME} {backend}\\n'\n", + encoding="utf-8", ) (fixture_root / "LICENSE").write_text("license\n", encoding="utf-8") (fixture_root / "README.md").write_text("readme\n", encoding="utf-8") @@ -84,7 +85,7 @@ def _stage_ready_files() -> tuple[Path, Path, Path]: model = parakeet_install.model_path() for path in (cpu, vulkan): path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("server\n", encoding="utf-8") + path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") path.chmod(0o755) model.parent.mkdir(parents=True, exist_ok=True) model.write_text("model\n", encoding="utf-8") @@ -189,9 +190,8 @@ def test_install_parakeet_server_relocates_and_chmods_binary( final_path = parakeet_install.binary_path("cpu") assert result["install_state"] == "verifying" assert final_path.exists() - assert ( - final_path.read_bytes() - == f"fake {parakeet_readiness.PARAKEET_CPP_BINARY_NAME} cpu".encode() + assert final_path.read_text(encoding="utf-8") == ( + f"#!/bin/sh\nprintf 'fake {parakeet_readiness.PARAKEET_CPP_BINARY_NAME} cpu\\n'\n" ) assert os.access(final_path, os.X_OK) assert (install_dir / "LICENSE").is_file() @@ -459,6 +459,44 @@ def test_ensure_artifacts_installed_resolves_requested_backend( assert parakeet_install.ensure_artifacts_installed("vulkan") == (vulkan, model) +def test_inspect_readiness_names_missing_openmp_runtime(tmp_path, monkeypatch) -> None: + _init_journal(tmp_path, monkeypatch) + _stage_ready_files() + monkeypatch.setattr( + parakeet_readiness, + "probe_parakeet_cpp_binary", + lambda _path: parakeet_readiness.ParakeetCppProbe( + runnable=False, + reason_code="openmp_runtime_unavailable", + detail=( + "error while loading shared libraries: libgomp.so.1: " + "cannot open shared object file" + ), + ), + ) + + readiness = parakeet_install.inspect_readiness() + + assert readiness.status == "host-ineligible" + assert readiness.reason_code == "openmp_runtime_unavailable" + assert readiness.host == { + "binary_runtime": { + "backend": "cpu", + "runnable": False, + "reason_code": "openmp_runtime_unavailable", + "detail": ( + "error while loading shared libraries: libgomp.so.1: " + "cannot open shared object file" + ), + } + } + + with pytest.raises(parakeet_install.ParakeetProviderError) as exc: + parakeet_install.ensure_artifacts_installed("cpu") + + assert exc.value.reason_code == "openmp_runtime_unavailable" + + def test_ensure_artifacts_installed_reports_missing_binary_and_model( tmp_path, monkeypatch ) -> None: diff --git a/tests/test_parakeet_readiness_stdlib.py b/tests/test_parakeet_readiness_stdlib.py index beeb31a5b..c223001cd 100644 --- a/tests/test_parakeet_readiness_stdlib.py +++ b/tests/test_parakeet_readiness_stdlib.py @@ -91,3 +91,69 @@ def test_check_parakeet_cpp_files_reports_missing_and_ready(tmp_path: Path) -> N "binary_vulkan": vulkan, "model": model, } + + +def test_probe_parakeet_cpp_binary_runs_real_executable(tmp_path: Path) -> None: + binary = tmp_path / "parakeet-server" + binary.write_text("#!/bin/sh\nprintf 'parakeet.cpp test\\n'\n", encoding="utf-8") + binary.chmod(0o755) + + result = parakeet_readiness.probe_parakeet_cpp_binary(binary) + + assert result.runnable is True + assert result.reason_code == "ready" + assert result.detail is None + + +def test_probe_parakeet_cpp_binary_names_missing_openmp_runtime( + tmp_path: Path, +) -> None: + binary = tmp_path / "parakeet-server" + binary.write_text( + "#!/bin/sh\n" + "printf '%s\\n' 'error while loading shared libraries: " + "libgomp.so.1: cannot open shared object file' >&2\n" + "exit 127\n", + encoding="utf-8", + ) + binary.chmod(0o755) + + result = parakeet_readiness.probe_parakeet_cpp_binary(binary) + + assert result.runnable is False + assert result.reason_code == "openmp_runtime_unavailable" + assert result.detail == ( + "error while loading shared libraries: libgomp.so.1: " + "cannot open shared object file" + ) + + +@pytest.mark.parametrize( + ("release", "expected"), + [ + ( + {"ID": "ubuntu", "ID_LIKE": "debian"}, + "install the OpenMP runtime with: sudo apt install libgomp1", + ), + ( + {"ID": "fedora"}, + "install the OpenMP runtime with: sudo dnf install libgomp", + ), + ( + {"ID": "arch"}, + "install the OpenMP runtime with: sudo pacman -S libgomp", + ), + ], +) +def test_openmp_runtime_install_guidance_names_verified_distro_package( + monkeypatch: pytest.MonkeyPatch, + release: dict[str, str], + expected: str, +) -> None: + monkeypatch.setattr( + parakeet_readiness.platform, + "freedesktop_os_release", + lambda: release, + ) + + assert parakeet_readiness.openmp_runtime_install_guidance() == expected diff --git a/tests/test_supervisor_provider_runtime.py b/tests/test_supervisor_provider_runtime.py index b80acedf0..801f87d44 100644 --- a/tests/test_supervisor_provider_runtime.py +++ b/tests/test_supervisor_provider_runtime.py @@ -1026,6 +1026,8 @@ def test_readiness_block_table_maps_artifact_and_proof_statuses( [ ("platform_unsupported", "platform-unsupported"), ("package_unavailable", "package-unavailable"), + ("binary_not_runnable", "package-unavailable"), + ("openmp_runtime_unavailable", "openmp-runtime-unavailable"), ("ram_insufficient", "ram-insufficient"), ("gpu_probe_failed", "gpu-probe-failed"), ("gpu_unavailable", "gpu-unavailable"),