Wake-word barge-in, Gitea auto-updater, hard_reset fix

This commit is contained in:
2026-07-23 07:30:08 -06:00
parent 80bef6f524
commit 843f52c507
13 changed files with 1127 additions and 47 deletions
+4 -1
View File
@@ -26,7 +26,10 @@
"Bash(.venv/bin/python *)",
"Bash(python *)",
"Bash(QT_QPA_PLATFORM=offscreen .venv/bin/python -m pytest tests/test_controller_features.py -q -p no:cacheprovider)",
"Bash(QT_QPA_PLATFORM=offscreen .venv/bin/python -c ' *)"
"Bash(QT_QPA_PLATFORM=offscreen .venv/bin/python -c ' *)",
"Bash(git push *)",
"Bash(git remote *)",
"Bash(grep -v '^$')"
]
}
}
+29 -5
View File
@@ -63,12 +63,36 @@ ELEVENLABS_VOICE_ID=
#PET_EDGE_SNAP=true
#PET_SNAP_MARGIN=48
# ── Barge-in (optional) — talk over the pet to cut it off ───────────────────
# Threshold defaults to 4x VAD_RMS_THRESHOLD because the mic also hears the
# pet's own voice out of the speakers. Raise it if playback self-interrupts.
# ── Barge-in (optional) — interrupt the pet mid-sentence ────────────────────
# BARGE_IN_MODE decides what counts as an interruption:
# wake — only the wake word cuts playback (default). Background noise,
# coughs and the TV can't stop it mid-sentence.
# energy — any sustained noise above BARGE_IN_RMS_THRESHOLD does. Faster to
# trigger, but interrupts on anything loud. That threshold defaults
# to 4x VAD_RMS_THRESHOLD because the mic also hears the pet's own
# voice out of the speakers; raise it if playback self-interrupts.
#BARGE_IN=true
#BARGE_IN_RMS_THRESHOLD=1200
#BARGE_IN_FRAMES=4
#BARGE_IN_MODE=wake
#BARGE_IN_RMS_THRESHOLD=1200 # energy mode only
#BARGE_IN_FRAMES=4 # energy mode only
# Wake mode only. Blank tracks the live WAKE_WORD_THRESHOLD (tray tuner);
# set a number to make interrupting harder than waking the pet from idle,
# e.g. if Bolt's own voice occasionally trips the model.
#BARGE_IN_WAKE_THRESHOLD=0.6
# ── Auto-update (optional) ──────────────────────────────────────────────────
# Watches the Gitea releases page for a tag newer than bolt_pet.__version__,
# then `git checkout`s it and restarts — only ever between turns, never
# mid-conversation. Requires the install to be a git clone; a working tree
# with local changes is skipped (never stashed), and any failure after the
# checkout rolls back to the ref that was live before.
#AUTO_UPDATE=true
#UPDATE_REPO_API=https://git.themajesticnetwork.com/api/v1/repos/TheMajesticNetwork/Bolt-Pet
#UPDATE_CHECK_INTERVAL_SECONDS=3600
#UPDATE_GIT_REMOTE=origin
#UPDATE_INSTALL_DEPS=true
# Only needed if the repo is private (a Gitea token with read:repository).
#UPDATE_TOKEN=
# ── Streaming TTS (optional) — starts talking on the first chunk ────────────
#TTS_STREAMING=true
+2
View File
@@ -3,3 +3,5 @@ __pycache__/
*.pyc
.env
.pytest_cache/
.claude
CLAUDE.md
+45 -5
View File
@@ -79,11 +79,16 @@ logs a missing-config message and exits its thread instead of starting.
`play_stream()` start playback on the first chunk; `chunks_to_int16()`
carries odd bytes across HTTP chunk boundaries, without which everything
after the first split sample plays as static — falling back to whole-clip
PCM then offline `pyttsx3`), `barge_in.py` (`BargeInDetector`: N consecutive
loud mic frames while the pet is talking cuts playback and starts the next
turn; threshold is deliberately ~4x the VAD one because the mic hears the
pet's own voice). Each accepts an injectable stream/model/protocol so tests
don't need real audio hardware or a display.
PCM then offline `pyttsx3`), `barge_in.py` (two detectors behind one
`reset()`/`check()` shape, chosen by `BARGE_IN_MODE` via `make_detector`:
**wake** (default) scores every frame with the same openWakeWord model the
idle listener uses, so only the wake phrase cuts playback; **energy** is the
original N-consecutive-loud-frames rule, threshold ~4x the VAD one because
the mic hears the pet's own voice. Wake mode shares `_default_model` with
the idle listener — the two never run concurrently — and `reset()`s it on
detection so the tail of one reply can't count toward the next). Each
accepts an injectable stream/model/protocol so tests don't need real audio
hardware or a display.
- **`pet_actions.py`** — `petctl` pseudo-commands (`petctl move top-left`,
`petctl emote wave`, `say`/`wander`/`nap`). The desk API has no "move the
pet" payload type and this repo can't change the server, so these ride the
@@ -103,6 +108,25 @@ logs a missing-config message and exits its thread instead of starting.
`dbus-monitor`, parses Notify calls (pure `iter_notifications()`), filters
and rate-limits them (`NotificationGate`), and the controller forwards
survivors through `converse()`. Off by default — each one is a round trip.
- **`updater.py`** — self-update from the Gitea releases API. Polls
`<UPDATE_REPO_API>/releases/latest` for a tag newer than
`bolt_pet.__version__` and moves the checkout to it with
`git fetch --tags` + `git checkout tags/<tag>`, so "downloading an update"
is just git and rolling back is one command. Three safety rules: a **dirty
working tree is skipped, never stashed** (silently discarding your
work-in-progress beats running an old version); everything after the
checkout — dependency install, then an **import smoke test in a
subprocess** (this process still has the old modules loaded, so importing
in-process would prove nothing) — is guarded, and any failure rolls back to
the exact ref that was live before, branch name or SHA; and the restart only
happens once the new code imports, so a broken release costs a log line
rather than a pet that won't start. Git goes through an injectable
`run(args) -> (code, output)` callable so apply/rollback is unit-tested
against a fake git; version comparison and release parsing are pure.
`controller._maybe_update` drives it from the wake-listener tick (so the pet
is IDLE and between turns by construction) and the actual `os.execv` happens
in `ui/app.py` *after* `app.exec()` returns — that ordering is what
guarantees the mic is released before the new process opens it.
- **`history.py`** — rolling transcript (`HISTORY_LIMIT` turns) behind the
tray's History window and click-to-copy on the bubble.
- **`hotkey.py`** — global push-to-talk via `pynput`; soft-fails with a logged
@@ -155,6 +179,22 @@ detection. Swap `WAKE_MODEL_FILE` to point at a differently-trained `.onnx`
model to change the wake phrase — everything downstream (STT, server call,
TTS) is unaffected.
**openwakeword's `Model.reset()` is not enough to forget a detection.** It
clears the *prediction* buffer only; the rolling audio window the classifier
actually scores lives in `model.preprocessor` (`raw_data_buffer` — 10s of raw
audio — plus `melspectrogram_buffer` and a ~120-frame `feature_buffer`) and
`AudioFeatures` has no reset method at all. So after a detection the wake
phrase is still in the window, and the next frame fed to the model re-fires on
it. Symptom when this bites: the pet cuts itself off a word into every reply,
because wake-mode barge-in resumes feeding the model and instantly matches the
"thunderbolt" that *started* the turn. `wake_word.hard_reset(model)` restores
the preprocessor to its as-constructed (silence) state and is what both
`listen_for_wake_word` and `WakeWordBargeIn.reset()` call — use it, not
`reset()`, anywhere a detection needs to be genuinely forgotten. The blank
state is cached on the preprocessor object (not in an `id()`-keyed dict —
CPython reuses ids after GC), since rebuilding it costs an ONNX pass over 10s
of silence.
The threshold is tunable at runtime: the tray's **Wake word tuning…** window
(`ui/wake_tuner.py`) shows the peak score seen and a rolling list of near
misses (frames within `WAKE_NEAR_MISS_MARGIN` *below* the threshold — i.e.
+7
View File
@@ -0,0 +1,7 @@
"""Bolt desktop pet.
__version__ is what the auto-updater compares against the newest tag on the
Gitea releases page (see updater.py), so bump it in the same commit you tag.
"""
__version__ = "0.1.0"
+149 -21
View File
@@ -1,26 +1,52 @@
"""Barge-in: notice that the user started talking *while the pet is talking*
so playback can be cut short mid-sentence.
"""Barge-in: notice that the user wants to interrupt *while the pet is
talking* so playback can be cut short mid-sentence.
Deliberately dumber than the utterance VAD in mic.py. The mic hears the pet's
own voice coming back out of the speakers, so a single loud frame proves
nothing — this requires several consecutive frames well above the normal
speech threshold (BARGE_IN_RMS_THRESHOLD defaults to 4x VAD_RMS_THRESHOLD).
Takes the same injectable stream shape as mic.record_utterance, so tests feed
it fake frames instead of real audio hardware.
Two detectors, picked by BARGE_IN_MODE:
- **wake** (default) — the interruption has to be the wake word. Every mic
frame goes through the same openWakeWord model the idle listener uses, so
a sneeze, a door, or the TV can't cut Bolt off mid-sentence; only saying
"thunderbolt" does.
- **energy** — the original behaviour: N consecutive frames above
BARGE_IN_RMS_THRESHOLD. Faster to trigger and needs no model inference,
but it fires on any sustained noise. Deliberately dumber than the
utterance VAD in mic.py, since the mic hears the pet's own voice coming
back out of the speakers, so the threshold defaults to 4x the VAD one.
Both take the same injectable stream shape as mic.record_utterance and expose
the same reset()/check() pair, so tests feed them fake frames instead of real
audio hardware and controller.py doesn't care which one it holds.
"""
from __future__ import annotations
from typing import Callable, Optional, Union
import numpy as np
from .. import config
from .mic import AudioStream, rms
from .wake_word import WakeModel, _default_model, hard_reset
def _read_frame(stream: AudioStream, frame_len: int) -> Optional[np.ndarray]:
"""One mono frame, or None if the mic hiccuped or gave us nothing. Never
raises: a bad frame mid-playback should mean "no barge-in this frame",
not a dead reply."""
try:
chunk, _ = stream.read(frame_len)
except Exception:
return None
frame = np.asarray(chunk)
if frame.ndim > 1:
frame = frame[:, 0]
return frame if frame.size else None
class BargeInDetector:
"""Poll-driven: call check() repeatedly while audio plays. Each call
consumes exactly one mic frame (80ms at the default frame length), which
is also what paces the playback loop's polling."""
"""Energy mode. Poll-driven: call check() repeatedly while audio plays.
Each call consumes exactly one mic frame (80ms at the default frame
length), which is also what paces the playback loop's polling."""
def __init__(
self,
@@ -44,19 +70,121 @@ class BargeInDetector:
def check(self) -> bool:
"""True once the user has been loud for long enough to count as an
interruption. Never raises: a mic hiccup mid-playback should not kill
the reply, it should just mean "no barge-in this frame"."""
try:
chunk, _ = self._stream.read(self._frame_len)
except Exception:
return False
frame = np.asarray(chunk)
if frame.ndim > 1:
frame = frame[:, 0]
if frame.size == 0:
interruption."""
frame = _read_frame(self._stream, self._frame_len)
if frame is None:
return False
if rms(frame) >= self._threshold:
self._loud_frames += 1
else:
self._loud_frames = 0 # a single thump/cough shouldn't count
return self._loud_frames >= self._required
class WakeWordBargeIn:
"""Wake-word mode: only "thunderbolt" interrupts.
Same per-frame predict() loop as listen_for_wake_word, just driven by the
playback poll instead of its own read loop. The model instance is shared
with the idle listener by default — the two never run at the same time
(the pipeline is either speaking or listening), and reusing it avoids
loading a second copy of the ONNX graph.
Two wrinkles the energy detector doesn't have:
- The mic hears the pet's own voice, so the model is scoring Bolt's
speech too. That's harmless unless Bolt says its own wake word, which
is why the threshold can be raised independently
(BARGE_IN_WAKE_THRESHOLD) without desensitizing the idle listener.
- reset() has to be a *hard* reset. openwakeword keeps ~10s of audio
history in its preprocessor, so the "thunderbolt" that started this
turn is still in the model's window when playback begins — feed it one
new frame and it fires on the old phrase, cutting the reply off a word
in. Clearing that window is what makes wake-mode barge-in work at all.
"""
def __init__(
self,
stream: AudioStream,
model: Optional[WakeModel] = None,
threshold: Union[float, Callable[[], float], None] = None,
frame_len: int = config.FRAME_LEN,
on_score: Optional[Callable[[float, float], None]] = None,
):
self._stream = stream
self._model = model if model is not None else _default_model
if threshold is None:
threshold = config.BARGE_IN_WAKE_THRESHOLD or config.WAKE_WORD_THRESHOLD
self._resolve_threshold = threshold if callable(threshold) else (lambda: threshold)
self._frame_len = frame_len
self._on_score = on_score
self._frames = 0
self._peak = 0.0
self._last = 0.0
self._last_threshold = 0.0
# Scoring history for the current reply. Without this an interruption is
# indistinguishable from a crash in the logs — you can't tell a genuine
# "thunderbolt" from the model firing on Bolt's own voice, or on the first
# frame (a stale window) versus halfway through (something it heard).
@property
def frames_checked(self) -> int:
return self._frames
@property
def seconds_checked(self) -> float:
return self._frames * self._frame_len / config.SAMPLE_RATE
@property
def peak_score(self) -> float:
return self._peak
@property
def last_score(self) -> float:
return self._last
@property
def last_threshold(self) -> float:
return self._last_threshold
def reset(self) -> None:
hard_reset(self._model) # never raises
self._frames = 0
self._peak = 0.0
self._last = 0.0
def check(self) -> bool:
frame = _read_frame(self._stream, self._frame_len)
if frame is None:
return False
try:
scores = self._model.predict(frame)
except Exception:
return False # same contract as a mic hiccup: no barge-in, no crash
threshold = self._resolve_threshold()
best = max(scores.values()) if scores else 0.0
self._frames += 1
self._last = best
self._peak = max(self._peak, best)
self._last_threshold = threshold
if self._on_score is not None:
self._on_score(best, threshold)
if scores and best >= threshold:
self.reset()
return True
return False
def make_detector(
stream: AudioStream,
mode: str = None,
wake_threshold: Union[float, Callable[[], float], None] = None,
on_score: Optional[Callable[[float, float], None]] = None,
):
"""Build whichever detector BARGE_IN_MODE asks for. An unrecognized mode
falls back to energy rather than raising — a typo in .env shouldn't stop
the pet from starting."""
mode = (config.BARGE_IN_MODE if mode is None else mode).strip().lower()
if mode in ("wake", "wakeword", "wake_word"):
return WakeWordBargeIn(stream, threshold=wake_threshold, on_score=on_score)
return BargeInDetector(stream)
+77 -3
View File
@@ -64,6 +64,65 @@ class NearMissLog:
self._peak = 0.0
# Where the cached blank state is stashed — on the preprocessor itself
# rather than in a dict keyed by id(), which CPython reuses after garbage
# collection and would hand one model another's buffers.
_BLANK_STATE_ATTR = "_bolt_blank_state"
def _blank_state(preprocessor) -> Optional[tuple]:
"""(feature_buffer, melspectrogram_buffer) as they are on a freshly
constructed model — i.e. "having heard nothing". Computing it costs an
ONNX pass over 10s of silence, so it's cached on the preprocessor and
copied from thereafter. Returns None if openwakeword's internals don't
look the way we expect, in which case callers leave the state alone
rather than corrupting it."""
cached = getattr(preprocessor, _BLANK_STATE_ATTR, None)
if cached is not None:
return cached
try:
# Same call the AudioFeatures constructor uses to prime the buffer.
state = (preprocessor._get_embeddings(np.zeros(160000).astype(np.int16)), np.ones((76, 32)))
setattr(preprocessor, _BLANK_STATE_ATTR, state)
except Exception:
return None
return state
def hard_reset(model) -> None:
"""Make the model forget the audio it has already heard — not just its
predictions.
openwakeword's ``Model.reset()`` clears the *prediction* buffer only.
The rolling audio window the classifier actually scores lives in
``model.preprocessor`` (raw_data_buffer / melspectrogram_buffer /
feature_buffer, ~10s of history) and has no reset method of its own. So
after a detection the wake word is still sitting in that window, and the
next frame fed to the model re-fires on it — which is exactly what made
the pet interrupt itself a word into every reply: the "thunderbolt" that
started the turn was still in the buffer when barge-in resumed feeding it.
Never raises. A model whose internals don't match (a fake in tests, a
future openwakeword release) just gets the plain reset()."""
try:
model.reset()
except Exception:
pass
preprocessor = getattr(model, "preprocessor", None)
if preprocessor is None:
return
blank = _blank_state(preprocessor)
try:
if blank is not None:
features, melspectrogram = blank
preprocessor.feature_buffer = features.copy()
preprocessor.melspectrogram_buffer = melspectrogram.copy()
preprocessor.raw_data_buffer.clear()
preprocessor.accumulated_samples = 0
except Exception:
pass
def _construct_model(model_cls, model_path: str):
"""openwakeword's Model() constructor keyword has drifted across
releases (wakeword_models -> wakeword_model_paths) and some builds
@@ -112,6 +171,17 @@ class _OpenWakeWordModel:
self._model = _construct_model(Model, config.WAKE_MODEL_PATH)
return self._model
@property
def preprocessor(self):
"""Proxy the wrapped model's audio-feature buffers.
Without this, hard_reset() sees a wrapper with no `preprocessor` and
silently degrades to openwakeword's shallow reset() — which leaves the
previous detection sitting in the audio window, i.e. exactly the bug
hard_reset exists to fix. Returns None before the model is loaded, so
a reset that happens first is a no-op rather than a load."""
return getattr(self._model, "preprocessor", None)
def predict(self, frame: np.ndarray) -> dict:
return self._ensure_model().predict(frame)
@@ -136,8 +206,9 @@ def listen_for_wake_word(
Feeds every frame to *model* (the thunderbolt openWakeWord model by
default) and treats any class score >= *threshold* as a detection,
resetting the model's internal state afterward so the next call starts
clean — same pattern as desk_client/bolt_desk.py's main loop.
clearing the model's internal state (audio window included, see
hard_reset) afterward so the next call starts clean — same pattern as
desk_client/bolt_desk.py's main loop.
*threshold* may be a number or a zero-argument callable. The callable
form exists because this function blocks for minutes at a time: the
@@ -180,6 +251,9 @@ def listen_for_wake_word(
on_score(best, current_threshold)
if scores and best >= current_threshold:
model.reset()
# hard_reset, not reset: the phrase has to leave the model's audio
# window too, or the very next frame we feed it re-fires on the
# same "thunderbolt" (see hard_reset's docstring).
hard_reset(model)
return True
return False
+37 -5
View File
@@ -82,15 +82,26 @@ MIN_UTTERANCE_S = float(os.environ.get("VAD_MIN_UTTERANCE_SECONDS", "0.4"))
COMMAND_TIMEOUT_SECONDS = int(os.environ.get("COMMAND_TIMEOUT_SECONDS", "30"))
HEARTBEAT_INTERVAL_SECONDS = float(os.environ.get("HEARTBEAT_INTERVAL_SECONDS", "60"))
# ── barge-in (interrupt playback by talking over it) ────────────────────────
# The mic stays live while the pet talks; sustained loud frames cut playback
# short. The threshold is deliberately well above VAD_RMS_THRESHOLD because
# the mic also hears the pet's own voice through the speakers — raise it
# further (or set BARGE_IN=false) if playback keeps interrupting itself.
# ── barge-in (interrupt playback while the pet is talking) ──────────────────
# The mic stays live while the pet talks. BARGE_IN_MODE decides what counts
# as an interruption:
# wake — only the wake word cuts playback (default). Immune to coughs,
# doors, and the TV, at the cost of ~a word of extra latency.
# energy — any sustained noise above BARGE_IN_RMS_THRESHOLD does. Faster,
# but interrupts on background noise. That threshold is well above
# VAD_RMS_THRESHOLD because the mic also hears the pet's own voice
# through the speakers — raise it further if playback keeps
# interrupting itself.
# Set BARGE_IN=false to make playback uninterruptible either way.
BARGE_IN = os.environ.get("BARGE_IN", "true").lower() in ("1", "true", "yes", "on")
BARGE_IN_MODE = os.environ.get("BARGE_IN_MODE", "wake")
BARGE_IN_RMS_THRESHOLD = int(os.environ.get("BARGE_IN_RMS_THRESHOLD", str(RMS_THRESHOLD * 4)))
BARGE_IN_FRAMES = int(os.environ.get("BARGE_IN_FRAMES", "4")) # consecutive loud frames (80ms each)
# Wake-mode sensitivity. Blank means "track the live WAKE_WORD_THRESHOLD from
# the tray tuner"; set a number to make interrupting deliberately harder than
# waking the pet from idle (useful if Bolt's own voice trips the model).
BARGE_IN_WAKE_THRESHOLD = float(os.environ.get("BARGE_IN_WAKE_THRESHOLD") or 0) or None
# ── streaming TTS ───────────────────────────────────────────────────────────
# ElevenLabs' /stream endpoint + chunked playback: the pet starts talking
@@ -140,6 +151,27 @@ PUSH_TO_TALK_HOTKEY = os.environ.get("PUSH_TO_TALK_HOTKEY", "ctrl+alt+space")
WAKE_NEAR_MISS_MARGIN = float(os.environ.get("WAKE_NEAR_MISS_MARGIN", "0.2"))
WAKE_NEAR_MISS_LIMIT = int(os.environ.get("WAKE_NEAR_MISS_LIMIT", "40"))
# ── auto-update ─────────────────────────────────────────────────────────────
# Watches the Gitea releases API for a tag newer than bolt_pet.__version__,
# then `git checkout`s it in place and restarts (see updater.py). The install
# has to be a git clone with a clean working tree — a dirty tree is skipped
# rather than stashed, so local edits are never thrown away. Any failure
# after checkout rolls back to the ref that was checked out before.
AUTO_UPDATE = os.environ.get("AUTO_UPDATE", "true").lower() in ("1", "true", "yes", "on")
UPDATE_REPO_API = os.environ.get(
"UPDATE_REPO_API",
"https://git.themajesticnetwork.com/api/v1/repos/TheMajesticNetwork/Bolt-Pet",
).rstrip("/")
UPDATE_CHECK_INTERVAL_SECONDS = float(os.environ.get("UPDATE_CHECK_INTERVAL_SECONDS", "3600"))
UPDATE_GIT_REMOTE = os.environ.get("UPDATE_GIT_REMOTE", "origin")
# Only needed if the repo is private — releases on a public repo read fine
# anonymously. A Gitea access token with read:repository.
UPDATE_TOKEN = os.environ.get("UPDATE_TOKEN", "")
# Reinstall requirements.txt when an update changes it. Off means a release
# that adds a dependency will roll straight back on the import smoke test.
UPDATE_INSTALL_DEPS = os.environ.get("UPDATE_INSTALL_DEPS", "true").lower() in ("1", "true", "yes", "on")
SAMPLE_RATE = 16000 # mic capture / STT rate
FRAME_LEN = 1280 # 80ms @ 16kHz — matches bolt_desk.py's chunking
+86 -3
View File
@@ -19,7 +19,10 @@ from typing import Optional
from PySide6.QtCore import QObject, Signal
from . import config, history as history_mod, notifications, pet_actions, quiet, screen_context, server_client, speech_text
from . import (
config, history as history_mod, notifications, pet_actions, quiet,
screen_context, server_client, speech_text, updater,
)
from .audio import barge_in, mic, stt, tts, wake_word
from .state import PetState, PetStateMachine
@@ -34,6 +37,7 @@ class PetController(QObject):
log = Signal(str)
action = Signal(dict) # parsed petctl action for the UI to perform
napping = Signal(bool) # quiet hours / fullscreen do-not-disturb
restart_requested = Signal(str) # version we just updated to
finished = Signal()
def __init__(self):
@@ -59,6 +63,9 @@ class PetController(QObject):
self._nap_forced: Optional[bool] = None # petctl nap on/off overrides the schedule
self._last_nap_check = 0.0
self._last_update_check = 0.0
self._update_pending = False # applied on disk, waiting for the restart
self._notification_watcher: Optional[notifications.NotificationWatcher] = None
self._notification_gate = notifications.NotificationGate(
config.NOTIFICATION_FILTER, config.NOTIFICATION_MIN_INTERVAL_SECONDS
@@ -124,7 +131,19 @@ class PetController(QObject):
return
if config.BARGE_IN:
self._barge_in = barge_in.BargeInDetector(self._stream)
# In wake mode the detector shares the idle listener's model and
# its live threshold, so the tray tuner's slider applies to
# interrupting as well as waking (unless BARGE_IN_WAKE_THRESHOLD
# pins it to a fixed, stricter number).
self._barge_in = barge_in.make_detector(
self._stream,
wake_threshold=(
config.BARGE_IN_WAKE_THRESHOLD
if config.BARGE_IN_WAKE_THRESHOLD is not None
else self.wake_threshold
),
)
self.log.emit(f"Barge-in: {config.BARGE_IN_MODE} mode.")
with self._stream:
try:
@@ -251,12 +270,33 @@ class PetController(QObject):
on_error=lambda exc: self.log.emit(f"TTS failed: {exc}"),
should_stop=should_stop,
)
if self._barge_in is not None:
# Playback fed the pet's own voice into the wake model's rolling
# window. Clear it before the idle listener starts scoring again,
# or Bolt's last sentence is still in there being re-scored.
self._barge_in.reset()
if not completed:
# You talked over it — take that as the start of the next turn
# rather than making you say the wake word again.
self.log.emit("Interrupted — listening.")
self.log.emit(f"Interrupted — listening. {self._barge_in_detail()}")
self._talk_now.set()
def _barge_in_detail(self) -> str:
"""Why the interruption fired, for the log. How far into playback it
happened is the tell: frame 1 means the detector was still holding
audio from before this reply started, whereas a hit several seconds
in is something the mic actually heard."""
detector = self._barge_in
if isinstance(detector, barge_in.WakeWordBargeIn):
return (
f"(wake score {detector.last_score:.3f} >= {detector.last_threshold:.2f}, "
f"peak {detector.peak_score:.3f}, at frame {detector.frames_checked} / "
f"{detector.seconds_checked:.1f}s into playback)"
)
if isinstance(detector, barge_in.BargeInDetector):
return f"(loud frames {detector.loud_frames}, threshold {config.BARGE_IN_RMS_THRESHOLD})"
return ""
# ── quiet hours / do-not-disturb ─────────────────────────────────────
def _apply_nap_state(self, napping: bool) -> None:
@@ -323,10 +363,53 @@ class PetController(QObject):
self._speak(reply)
self._state.transition(PetState.IDLE)
# ── auto-update ──────────────────────────────────────────────────────
def _maybe_update(self) -> None:
"""Poll the Gitea releases page and, if there's a newer tag, apply it
and ask the UI to restart.
Only ever runs from the wake-listener's tick, so the pet is IDLE and
between turns by construction — an update can't land mid-sentence.
Failures are logged and the interval resets, so a server that's down
(or a release that rolls back) costs one log line an hour, not a
retry storm."""
if not config.AUTO_UPDATE or self._update_pending:
return
now = time.monotonic()
if now - self._last_update_check < config.UPDATE_CHECK_INTERVAL_SECONDS:
return
self._last_update_check = now
try:
release = updater.check_for_update()
except updater.UpdateError as exc:
self.log.emit(f"Update check failed: {exc}")
return
if release is None:
return
self.log.emit(f"Update available: {release.tag} — applying.")
try:
previous = updater.apply_update(release.tag, on_log=self.log.emit)
except updater.UpdateError as exc:
self.log.emit(f"Update to {release.tag} failed: {exc}")
return
self._update_pending = True
self.log.emit(f"Updated {previous} -> {release.tag}; restarting.")
if not self._napping:
# Napping means no proactive noise, so a silent restart it is.
self._speak(f"Updating to {release.tag}. Back in a second.")
self._state.transition(PetState.IDLE)
self.restart_requested.emit(release.tag)
# ── heartbeat ────────────────────────────────────────────────────────
def _maybe_heartbeat(self) -> None:
self._refresh_nap_state()
self._maybe_update()
if self._update_pending:
return # on the way out — don't start a conversation now
now = time.monotonic()
if now - self._last_heartbeat < config.HEARTBEAT_INTERVAL_SECONDS:
return
+23 -2
View File
@@ -13,7 +13,7 @@ import sys
from PySide6.QtCore import QThread
from PySide6.QtWidgets import QApplication
from .. import config
from .. import config, updater
from ..controller import PetController
from ..hotkey import GlobalHotkey
from ..state import PetState
@@ -91,6 +91,19 @@ def run() -> int:
elif hotkey.running:
_log(f"Push-to-talk: {config.PUSH_TO_TALK_HOTKEY}")
# The updater has already moved the checkout by the time this fires; all
# that's left is to let Qt tear down cleanly (so the mic and the tray
# icon are released) and then exec the new code. Doing the exec after
# app.exec() returns, rather than from the controller thread, is what
# guarantees the audio device is free before the new process opens it.
pending_restart = {"tag": None}
def _handle_restart(tag: str) -> None:
pending_restart["tag"] = tag
app.quit()
controller.restart_requested.connect(_handle_restart)
def _shutdown() -> None:
hotkey.stop()
controller.stop()
@@ -100,4 +113,12 @@ def run() -> int:
app.aboutToQuit.connect(_shutdown)
thread.start()
return app.exec()
status = app.exec()
if pending_restart["tag"]:
_log(f"Restarting into {pending_restart['tag']}")
try:
updater.restart() # never returns
except Exception as exc:
_log(f"Couldn't restart automatically ({exc}) — start the pet again by hand.")
return status
+299
View File
@@ -0,0 +1,299 @@
"""Self-update from the Gitea releases page.
Polls `<UPDATE_REPO_API>/releases/latest` for a tag newer than
``bolt_pet.__version__`` and, if there is one, moves the checkout to that tag
and restarts the pet. The install is expected to be a git clone (which is how
it's deployed), so "download the update" is just `git fetch` + `git checkout`
— atomic, and the previous ref is one command away if anything goes wrong.
Safety rules, in the order they're enforced:
1. **A dirty working tree is never touched.** Local edits are skipped over,
not stashed — the pet silently discarding your work-in-progress would be
far worse than running an old version.
2. **Everything after checkout is guarded.** Dependency install and an import
smoke test both run before the restart; if either fails, the checkout is
rolled back to the exact ref that was live before (branch name if we were
on one, otherwise the commit) and the deps reinstalled from it.
3. **The restart only happens once the new code imports.** So a broken
release costs you a rollback and a log line, not a pet that won't start.
The git side goes through an injectable *run* callable — ``(args) ->
(returncode, output)`` — so the whole apply/rollback dance is unit-tested
against a fake git rather than a real repo. Version comparison and release
parsing are pure functions for the same reason.
"""
from __future__ import annotations
import os
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Optional, Tuple
import requests
from . import config
# (returncode, combined stdout+stderr)
GitResult = Tuple[int, str]
GitRunner = Callable[[list], GitResult]
_GIT_TIMEOUT_SECONDS = 300
class UpdateError(Exception):
"""Raised when an update can't be applied. If it's raised *after* the
checkout moved, the rollback has already run."""
@dataclass(frozen=True)
class Release:
tag: str
name: str
body: str
prerelease: bool
# ── pure logic ───────────────────────────────────────────────────────────────
def parse_version(tag: str) -> tuple:
"""``"v1.2.3"`` -> ``(1, 2, 3)``. Leading "v" optional; a trailing
suffix ends the parse (``"1.2.3-beta1"`` -> ``(1, 2, 3)``), so a
prerelease of a version compares equal to it rather than sorting
randomly. Junk parses to ``()``, which is never newer than anything."""
parts: list[int] = []
for chunk in (tag or "").strip().lstrip("vV").split("."):
digits = ""
for char in chunk:
if not char.isdigit():
break
digits += char
if not digits:
break
parts.append(int(digits))
return tuple(parts)
def is_newer(candidate: str, current: str) -> bool:
"""True if *candidate* is a strictly newer version than *current*.
Compares zero-padded, so 1.2 == 1.2.0 and 1.2.1 > 1.2."""
new, old = parse_version(candidate), parse_version(current)
if not new:
return False
width = max(len(new), len(old))
return new + (0,) * (width - len(new)) > old + (0,) * (width - len(old))
def release_from_payload(payload: dict) -> Optional[Release]:
"""Gitea's release JSON -> Release, or None if it's a draft or has no
tag. /releases/latest already excludes drafts and prereleases, but the
same parser is used for the full list."""
if not isinstance(payload, dict) or payload.get("draft"):
return None
tag = str(payload.get("tag_name") or "").strip()
if not tag:
return None
return Release(
tag=tag,
name=str(payload.get("name") or tag),
body=str(payload.get("body") or ""),
prerelease=bool(payload.get("prerelease")),
)
# ── talking to Gitea ─────────────────────────────────────────────────────────
def fetch_latest_release(api_url: str = None, token: str = None, timeout: float = 15.0) -> Optional[Release]:
"""Newest published release, or None if the repo has no releases yet
(a fresh repo 404s here, which is not an error worth logging every hour).
Raises UpdateError if the server is unreachable or answers with junk."""
api_url = (api_url if api_url is not None else config.UPDATE_REPO_API).rstrip("/")
if not api_url:
raise UpdateError("UPDATE_REPO_API is not set")
token = config.UPDATE_TOKEN if token is None else token
headers = {"Authorization": f"token {token}"} if token else {}
try:
response = requests.get(f"{api_url}/releases/latest", headers=headers, timeout=timeout)
except Exception as exc:
raise UpdateError(f"couldn't reach the releases API: {exc}") from exc
if response.status_code == 404:
return None
try:
response.raise_for_status()
payload = response.json()
except Exception as exc:
raise UpdateError(f"bad response from the releases API: {exc}") from exc
return release_from_payload(payload)
def check_for_update(current_version: str = None, **kwargs) -> Optional[Release]:
"""The whole "is there anything new?" question in one call. Returns the
Release to move to, or None if we're already current."""
from . import __version__
current = __version__ if current_version is None else current_version
release = fetch_latest_release(**kwargs)
if release is None or not is_newer(release.tag, current):
return None
return release
# ── git ──────────────────────────────────────────────────────────────────────
def git_runner(repo: Path = None) -> GitRunner:
repo = Path(repo or config.HERE)
def run(args: list) -> GitResult:
try:
completed = subprocess.run(
["git", *args], cwd=str(repo), capture_output=True,
text=True, timeout=_GIT_TIMEOUT_SECONDS,
)
except Exception as exc:
return 1, f"git {' '.join(args)} failed to start: {exc}"
return completed.returncode, ((completed.stdout or "") + (completed.stderr or "")).strip()
return run
def is_git_clone(run: GitRunner) -> bool:
return run(["rev-parse", "--git-dir"])[0] == 0
def working_tree_dirty(run: GitRunner) -> bool:
code, output = run(["status", "--porcelain"])
return code != 0 or bool(output.strip())
def current_ref(run: GitRunner) -> str:
"""The branch name if we're on one, else the commit SHA — i.e. whatever
`git checkout` needs to put things back exactly as they were."""
code, output = run(["symbolic-ref", "--quiet", "--short", "HEAD"])
if code == 0 and output.strip():
return output.strip()
code, output = run(["rev-parse", "HEAD"])
if code != 0 or not output.strip():
raise UpdateError("couldn't work out the current git ref")
return output.strip()
def _requirements_changed(run: GitRunner, before: str, after: str) -> bool:
code, output = run(["diff", "--name-only", before, after, "--", "requirements.txt"])
return code == 0 and bool(output.strip())
def _install_deps(repo: Path) -> None:
completed = subprocess.run(
[sys.executable, "-m", "pip", "install", "-r", "requirements.txt"],
cwd=str(repo), capture_output=True, text=True, timeout=_GIT_TIMEOUT_SECONDS,
)
if completed.returncode != 0:
raise UpdateError(f"pip install failed: {(completed.stderr or '')[-500:]}")
def _smoke_test(repo: Path) -> None:
"""Import the freshly checked-out package in a *subprocess* — this one
still has the old modules loaded, so importing here would prove nothing.
Catches the common broken release (syntax error, missing dependency)
before we hand the session over to it."""
completed = subprocess.run(
[sys.executable, "-c", "import bolt_pet; import bolt_pet.controller"],
cwd=str(repo), capture_output=True, text=True, timeout=120,
)
if completed.returncode != 0:
raise UpdateError(f"the new version failed to import: {(completed.stderr or '')[-500:]}")
def apply_update(
tag: str,
run: GitRunner = None,
repo: Path = None,
on_log: Callable[[str], None] = lambda _msg: None,
install_deps: bool = None,
verify: Callable[[Path], None] = None,
) -> str:
"""Move the checkout to *tag*, rolling back to where it was if anything
downstream of the checkout fails. Returns the ref we came from (handy for
logging / a manual `git checkout` back). Raises UpdateError otherwise."""
repo = Path(repo or config.HERE)
run = run or git_runner(repo)
install_deps = config.UPDATE_INSTALL_DEPS if install_deps is None else install_deps
verify = _smoke_test if verify is None else verify
if not is_git_clone(run):
raise UpdateError("not a git clone — auto-update only works on a git checkout")
if working_tree_dirty(run):
raise UpdateError("working tree has local changes — skipping (nothing was touched)")
previous = current_ref(run)
code, output = run(["fetch", "--tags", "--prune", config.UPDATE_GIT_REMOTE])
if code != 0:
raise UpdateError(f"git fetch failed: {output}")
code, output = run(["checkout", "--force", f"tags/{tag}"])
if code != 0:
raise UpdateError(f"git checkout {tag} failed: {output}")
on_log(f"Checked out {tag} (was {previous}).")
# Past this point every failure has to put the checkout back.
try:
if install_deps and _requirements_changed(run, previous, f"tags/{tag}"):
on_log("requirements.txt changed — installing.")
_install_deps(repo)
verify(repo)
except UpdateError as exc:
_rollback(run, previous, repo, on_log, install_deps)
raise UpdateError(f"{exc} — rolled back to {previous}") from exc
except Exception as exc: # a verify() that blows up is still a failed update
_rollback(run, previous, repo, on_log, install_deps)
raise UpdateError(f"update failed ({exc}) — rolled back to {previous}") from exc
return previous
def _rollback(
run: GitRunner,
previous: str,
repo: Path,
on_log: Callable[[str], None],
install_deps: bool,
) -> None:
"""Best-effort return to *previous*. Never raises — it's already running
inside a failure path, and the caller's UpdateError is the thing worth
surfacing. A rollback that itself fails gets its own loud log line,
because that's the one case needing a human."""
code, output = run(["checkout", "--force", previous])
if code != 0:
on_log(f"ROLLBACK FAILED — the checkout is stranded. Run: git checkout {previous} ({output})")
return
on_log(f"Rolled back to {previous}.")
if install_deps:
try:
_install_deps(repo)
except Exception as exc:
on_log(f"Rolled back, but reinstalling the old requirements failed: {exc}")
# ── restart ──────────────────────────────────────────────────────────────────
def restart() -> None:
"""Replace this process with a fresh `python -m bolt_pet`.
execv rather than spawn-and-exit so there's no window with two pets
holding the same mic, and no orphan if the parent dies first. Never
returns when it works; callers should have shut the Qt app and released
the audio device before calling it.
chdir first because `-m bolt_pet` resolves the package from the working
directory: the pet may well have been launched from somewhere else
(autostart entry, run.sh invoked by path), and the new process has to
land on the checkout the update was just applied to."""
os.chdir(str(config.HERE))
os.execv(sys.executable, [sys.executable, "-m", "bolt_pet"])
+155 -2
View File
@@ -1,4 +1,5 @@
"""Barge-in detection, driven by a fake mic stream (no audio hardware)."""
"""Barge-in detection, driven by a fake mic stream and a fake wake model
(no audio hardware, no ONNX runtime)."""
import sys
from pathlib import Path
@@ -7,7 +8,7 @@ import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from bolt_pet.audio.barge_in import BargeInDetector
from bolt_pet.audio.barge_in import BargeInDetector, WakeWordBargeIn, make_detector
class FakeStream:
@@ -63,3 +64,155 @@ def test_a_mic_error_mid_playback_is_not_fatal():
detector = BargeInDetector(BrokenStream(), threshold=1000, required_frames=1)
assert detector.check() is False
# ── wake-word mode ───────────────────────────────────────────────────────────
class FakeModel:
"""Scores frames from a canned list, mimicking openWakeWord's
{class_name: score} return. Records reset() calls."""
def __init__(self, scores):
self._scores = list(scores)
self.resets = 0
def predict(self, frame):
score = self._scores.pop(0) if self._scores else 0.0
return {"thunderbolt": score}
def reset(self):
self.resets += 1
def _wake_detector(scores, threshold=0.5, amplitudes=None):
stream = FakeStream(amplitudes if amplitudes is not None else [500] * len(scores))
return WakeWordBargeIn(stream, model=FakeModel(scores), threshold=threshold), stream
def test_loud_noise_alone_does_not_interrupt_in_wake_mode():
"""The whole point of wake mode: a slammed door is deafening and scores
nothing, so the pet keeps talking."""
detector, _ = _wake_detector([0.01] * 6, amplitudes=[30000] * 6)
assert not any(detector.check() for _ in range(6))
def test_the_wake_word_interrupts():
detector, _ = _wake_detector([0.1, 0.2, 0.9])
assert [detector.check() for _ in range(3)] == [False, False, True]
def test_a_single_frame_is_enough_when_it_clears_the_threshold():
detector, _ = _wake_detector([0.55])
assert detector.check() is True
def test_scores_just_under_the_threshold_do_not_fire():
detector, _ = _wake_detector([0.49, 0.499], threshold=0.5)
assert not any(detector.check() for _ in range(2))
def test_detecting_resets_the_model_so_the_tail_is_not_reused():
model = FakeModel([0.9])
detector = WakeWordBargeIn(FakeStream([500]), model=model, threshold=0.5)
assert detector.check() is True
assert model.resets == 1
def test_reset_clears_the_models_audio_window_not_just_predictions():
"""The regression that made the pet interrupt itself a word into every
reply: openwakeword's reset() clears only the prediction buffer, so the
"thunderbolt" that started the turn was still in the preprocessor's
rolling window when playback began, and the first frame fed to the model
re-fired on it."""
class FakePreprocessor:
def __init__(self):
self.raw_data_buffer = [1, 2, 3]
self.feature_buffer = np.ones((120, 96))
self.melspectrogram_buffer = np.zeros((76, 32))
self.accumulated_samples = 4096
def _get_embeddings(self, audio):
return np.zeros((120, 96))
model = FakeModel([0.9])
model.preprocessor = FakePreprocessor()
WakeWordBargeIn(FakeStream([500]), model=model, threshold=0.5).reset()
assert model.preprocessor.raw_data_buffer == []
assert model.preprocessor.accumulated_samples == 0
assert not model.preprocessor.feature_buffer.any() # blank, not the old audio
assert model.preprocessor.melspectrogram_buffer.all() # restored to ones
def test_the_lazy_wrapper_exposes_its_preprocessor():
"""The pet holds _default_model — a lazy *wrapper* around openwakeword's
Model. If the wrapper stops proxying .preprocessor, hard_reset() finds
nothing to clear and silently degrades to the shallow reset that leaves
the last detection in the audio window. That failure is invisible: no
exception, no log, the pet just interrupts itself again."""
from bolt_pet.audio.wake_word import _OpenWakeWordModel
wrapper = _OpenWakeWordModel()
assert hasattr(wrapper, "preprocessor")
assert wrapper.preprocessor is None # not loaded yet: a no-op, not a load
class FakeInner:
preprocessor = object()
def reset(self):
pass
wrapper._model = FakeInner()
assert wrapper.preprocessor is FakeInner.preprocessor
def test_a_model_without_a_preprocessor_still_resets():
"""Fakes in tests, and any future openwakeword whose internals moved."""
model = FakeModel([0.0])
WakeWordBargeIn(FakeStream([500]), model=model, threshold=0.5).reset()
assert model.resets == 1
def test_a_callable_threshold_is_read_every_frame():
"""The tray tuner's slider has to apply mid-playback, not just mid-idle."""
threshold = {"value": 0.9}
detector = WakeWordBargeIn(
FakeStream([500] * 2), model=FakeModel([0.6, 0.6]),
threshold=lambda: threshold["value"],
)
assert detector.check() is False
threshold["value"] = 0.5
assert detector.check() is True
def test_a_model_that_blows_up_mid_playback_is_not_fatal():
class BrokenModel:
def predict(self, frame):
raise RuntimeError("onnx session died")
def reset(self):
raise RuntimeError("still dead")
detector = WakeWordBargeIn(FakeStream([500]), model=BrokenModel(), threshold=0.5)
assert detector.check() is False
detector.reset() # must not raise either
def test_a_mic_error_is_not_fatal_in_wake_mode():
class BrokenStream:
def read(self, frames):
raise OSError("device disappeared")
detector = WakeWordBargeIn(BrokenStream(), model=FakeModel([0.9]), threshold=0.5)
assert detector.check() is False
def test_make_detector_picks_the_mode():
stream = FakeStream([0])
assert isinstance(make_detector(stream, mode="wake"), WakeWordBargeIn)
assert isinstance(make_detector(stream, mode="energy"), BargeInDetector)
# A typo in .env shouldn't stop the pet from starting.
assert isinstance(make_detector(stream, mode="waek"), BargeInDetector)
+214
View File
@@ -0,0 +1,214 @@
"""Auto-updater: version comparison, release parsing, and the apply/rollback
dance driven by a fake git (no network, no real repo, nothing checked out)."""
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from bolt_pet import updater
# ── version comparison ───────────────────────────────────────────────────────
@pytest.mark.parametrize(
"tag,expected",
[
("v1.2.3", (1, 2, 3)),
("1.2.3", (1, 2, 3)),
("V0.1.0", (0, 1, 0)),
("1.2", (1, 2)),
("1.2.3-beta1", (1, 2, 3)), # suffix ends the parse
("", ()),
("nightly", ()),
],
)
def test_parse_version(tag, expected):
assert updater.parse_version(tag) == expected
@pytest.mark.parametrize(
"candidate,current",
[("0.2.0", "0.1.0"), ("1.0.0", "0.9.9"), ("0.1.1", "0.1"), ("v2.0", "1.9.9")],
)
def test_is_newer_accepts_newer_versions(candidate, current):
assert updater.is_newer(candidate, current)
@pytest.mark.parametrize(
"candidate,current",
[
("0.1.0", "0.1.0"),
("0.1.0", "0.2.0"),
("0.1", "0.1.0"), # zero-padded: equal, not newer
("", "0.1.0"),
("nightly", "0.1.0"), # unparseable is never newer
],
)
def test_is_newer_rejects_same_or_older(candidate, current):
assert not updater.is_newer(candidate, current)
def test_release_parsing_skips_drafts_and_untagged():
assert updater.release_from_payload({"tag_name": "v1.0.0", "draft": True}) is None
assert updater.release_from_payload({"name": "no tag"}) is None
release = updater.release_from_payload({"tag_name": "v1.0.0", "name": "One", "prerelease": True})
assert (release.tag, release.name, release.prerelease) == ("v1.0.0", "One", True)
# ── fake git ─────────────────────────────────────────────────────────────────
class FakeGit:
"""Records every git invocation and answers from a canned script.
*failures* maps a leading-args tuple to the (code, output) it should
return, so a test can make exactly one command fail."""
def __init__(self, ref="main", dirty=False, failures=None, requirements_changed=False):
self.calls = []
self._ref = ref
self._dirty = dirty
self._failures = failures or {}
self._requirements_changed = requirements_changed
def __call__(self, args):
self.calls.append(list(args))
for prefix, result in self._failures.items():
if tuple(args[: len(prefix)]) == prefix:
return result
head = args[0]
if head == "rev-parse" and args[1] == "--git-dir":
return 0, ".git"
if head == "status":
return 0, " M bolt_pet/config.py" if self._dirty else ""
if head == "symbolic-ref":
return (0, self._ref) if self._ref else (1, "")
if head == "rev-parse":
return 0, "abc1234"
if head == "diff":
return 0, "requirements.txt" if self._requirements_changed else ""
return 0, ""
def commands(self):
"""Just the verbs, for asserting on the sequence."""
return [call[0] for call in self.calls]
def test_apply_update_checks_out_the_tag(tmp_path):
git = FakeGit()
previous = updater.apply_update(
"v1.0.0", run=git, repo=tmp_path, install_deps=False, verify=lambda repo: None
)
assert previous == "main"
assert ["fetch", "--tags", "--prune", "origin"] in git.calls
assert ["checkout", "--force", "tags/v1.0.0"] in git.calls
def test_a_dirty_working_tree_is_left_completely_alone(tmp_path):
git = FakeGit(dirty=True)
with pytest.raises(updater.UpdateError, match="local changes"):
updater.apply_update("v1.0.0", run=git, repo=tmp_path, install_deps=False)
assert "fetch" not in git.commands()
assert "checkout" not in git.commands()
def test_a_non_git_install_refuses_before_touching_anything(tmp_path):
git = FakeGit(failures={("rev-parse", "--git-dir"): (128, "not a repository")})
with pytest.raises(updater.UpdateError, match="not a git clone"):
updater.apply_update("v1.0.0", run=git, repo=tmp_path, install_deps=False)
assert "checkout" not in git.commands()
def test_a_failed_smoke_test_rolls_back_to_the_previous_ref(tmp_path):
git = FakeGit(ref="main")
def broken(repo):
raise updater.UpdateError("the new version failed to import: boom")
with pytest.raises(updater.UpdateError, match="rolled back to main"):
updater.apply_update("v1.0.0", run=git, repo=tmp_path, install_deps=False, verify=broken)
checkouts = [call for call in git.calls if call[0] == "checkout"]
assert checkouts == [["checkout", "--force", "tags/v1.0.0"], ["checkout", "--force", "main"]]
def test_rollback_targets_the_commit_when_head_is_detached(tmp_path):
# No branch to go back to (symbolic-ref fails) — the SHA is the ref.
git = FakeGit(ref="")
with pytest.raises(updater.UpdateError):
updater.apply_update(
"v1.0.0", run=git, repo=tmp_path, install_deps=False,
verify=lambda repo: (_ for _ in ()).throw(RuntimeError("nope")),
)
assert ["checkout", "--force", "abc1234"] in git.calls
def test_a_verify_that_raises_something_unexpected_still_rolls_back(tmp_path):
git = FakeGit()
def exploding(repo):
raise ValueError("not even an UpdateError")
with pytest.raises(updater.UpdateError, match="rolled back"):
updater.apply_update(
"v1.0.0", run=git, repo=tmp_path, install_deps=False, verify=exploding
)
assert ["checkout", "--force", "main"] in git.calls
def test_a_failed_fetch_never_moves_the_checkout(tmp_path):
git = FakeGit(failures={("fetch",): (1, "could not resolve host")})
with pytest.raises(updater.UpdateError, match="git fetch failed"):
updater.apply_update("v1.0.0", run=git, repo=tmp_path, install_deps=False)
assert "checkout" not in git.commands()
def test_a_stranded_checkout_is_logged_loudly(tmp_path):
"""Rollback itself failing is the one case a human has to fix by hand."""
git = FakeGit(failures={("checkout", "--force", "main"): (1, "index locked")})
logs = []
with pytest.raises(updater.UpdateError):
updater.apply_update(
"v1.0.0", run=git, repo=tmp_path, install_deps=False, on_log=logs.append,
verify=lambda repo: (_ for _ in ()).throw(updater.UpdateError("bad build")),
)
assert any("ROLLBACK FAILED" in line and "git checkout main" in line for line in logs)
def test_deps_are_only_reinstalled_when_requirements_actually_changed(tmp_path, monkeypatch):
installs = []
monkeypatch.setattr(updater, "_install_deps", lambda repo: installs.append(repo))
updater.apply_update(
"v1.0.0", run=FakeGit(requirements_changed=False), repo=tmp_path,
install_deps=True, verify=lambda repo: None,
)
assert installs == []
updater.apply_update(
"v1.0.0", run=FakeGit(requirements_changed=True), repo=tmp_path,
install_deps=True, verify=lambda repo: None,
)
assert installs == [tmp_path]
# ── release checking ─────────────────────────────────────────────────────────
def test_check_for_update_returns_nothing_when_current(monkeypatch):
monkeypatch.setattr(
updater, "fetch_latest_release",
lambda **kwargs: updater.Release("v0.1.0", "0.1.0", "", False),
)
assert updater.check_for_update(current_version="0.1.0") is None
assert updater.check_for_update(current_version="0.0.9").tag == "v0.1.0"
def test_no_releases_yet_is_not_an_error(monkeypatch):
monkeypatch.setattr(updater, "fetch_latest_release", lambda **kwargs: None)
assert updater.check_for_update(current_version="0.1.0") is None