Files
emozilla 4bc2efbeb3 fix(local): keep long local-model turns alive on laptops
A press reviewer ran Qwen 27B on a 64 GB RTX Spark and saw single queries take hours. The
causes, and what each part of this change does about them:

- llama-server reads a long prompt for minutes before the first token. Local requests now
  always stream and ask for prompt-reading progress, so the stall timers see the server
  working and the router notices a hang-up within seconds. They no longer fall back to a
  non-streamed request, which the router can't cancel. The status line shows how much of
  the prompt the server has read.
- A laptop that sleeps mid-request froze the request, and the stall timer cut it on wake.
  Stall timers for a server on the same machine now skip time the process was stopped
  (sleep or Modern Standby). Background-call watchdogs run on the awake clock.
- After repeated stalls the breaker failed every later message at once, even after the local
  server recovered. At turn start, a local server that answers its health check gets one more
  attempt; one more stall shuts the breaker again.
- On Windows unified memory, the GPU carve-out is invisible to Windows, and the model plus
  llama-server's default 8 GiB prompt cache overran commit. Free commit now narrows the launch
  window (never refusing a model the pool can hold), caps how many models the router holds at
  once, and sizes the prompt cache, split across those models.
- Hermes's status reads named the model, and the router loads any model a request names, so
  Eject and the idle unload reloaded the model at once. Status reads pass autoload=false; an
  unloaded model counts as idle, and the context probe reads the router's launch args.
- The smart-approval verdict has a 16-token budget, and a thinking model spent all of it
  thinking (45 of 45 verdicts came back empty). The verdict asks for no thinking unless the
  operator set auxiliary.approval.reasoning_effort.
- Compression's 600 s limit counted the time the server spent reading a 150K-token
  conversation, which alone can exceed it on a laptop GPU. Prompt reading now pushes the fence
  deadline back (CompressionCommitFence.extend_deadline), only while the server reports newly read
  tokens, and never past a fence the host closed or one a commit holds. The agent and gateway
  hygiene waits read the moved deadline after every slice.
- Desktop keep-awake defaults to "while working". When a timed sleep interrupts a turn with
  keep-awake off, the next wake offers it once per computer; the offer survives the next prompt.

(cherry picked from commit 518ce272b9)
2026-10-16 15:21:07 -04:00

58 lines
2.1 KiB
Python

"""Clocks that stop while the computer sleeps.
A laptop that sleeps in the middle of a request freezes Hermes and the local model server
together. Timers measured on the wall clock or on Python's ``time.monotonic`` on Windows keep
counting through the sleep, so every stall watchdog fires the moment the machine wakes and kills
work that was about to continue. Watchdogs that judge "no progress" use these clocks instead.
"""
from __future__ import annotations
import sys
import time
from collections.abc import Callable
_SLEEP_THRESHOLD_S = 5.0
def _windows_unbiased_clock() -> Callable[[], float] | None:
"""The unbiased interrupt time in seconds, or None where kernel32 lacks it."""
try:
import ctypes
query = ctypes.windll.kernel32.QueryUnbiasedInterruptTime
except (AttributeError, OSError):
return None
def clock() -> float:
value = ctypes.c_ulonglong(0)
query(ctypes.byref(value))
return value.value / 10_000_000
return clock
#: Seconds the machine has spent awake, from an arbitrary start. Windows: the unbiased interrupt
#: time, which excludes sleep and hibernation. Linux and macOS: ``time.monotonic``, which already
#: stops during suspend there.
awake_seconds: Callable[[], float] = (
(_windows_unbiased_clock() if sys.platform == "win32" else None) or time.monotonic)
class SleepWatch:
"""Reports how long this process was stopped since the last check, for a caller that checks
every ``interval`` seconds: the wall time past one interval. That covers a machine that slept
and a process that Modern Standby froze, without relying on how any clock treats either.
Gaps under five seconds (clock sync, scheduling noise) don't count.
"""
def __init__(self, interval: float) -> None:
self._interval = interval
self._wall = time.time()
def slept(self) -> float:
"""Seconds stopped since the previous call, or 0.0."""
wall = time.time()
stopped, self._wall = wall - self._wall - self._interval, wall
return stopped if stopped >= _SLEEP_THRESHOLD_S else 0.0