Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87bc30204c | |||
| 54cde17e73 | |||
| 4706f78a98 | |||
| 9ead1db047 |
@@ -40,9 +40,6 @@ deploy/telegram.env
|
|||||||
deploy/zenmoney.token
|
deploy/zenmoney.token
|
||||||
# IMAP password, read by mavmaild (never in argv, never committed)
|
# IMAP password, read by mavmaild (never in argv, never committed)
|
||||||
deploy/imap.password
|
deploy/imap.password
|
||||||
# Compose interpolation secrets — MAVEN_AMBIENT_TOKEN today. docker compose
|
|
||||||
# reads this file itself; it is not an env_file on any service.
|
|
||||||
/.env
|
|
||||||
|
|
||||||
# Temp files
|
# Temp files
|
||||||
/tmp/
|
/tmp/
|
||||||
|
|||||||
@@ -28,14 +28,6 @@ model is a one-line change to `phraser.model_path` in `deploy/mavend.json`.
|
|||||||
See `docs/rearchitecture.md` for the target architecture, `docs/design.md` for the folded design spec, and
|
See `docs/rearchitecture.md` for the target architecture, `docs/design.md` for the folded design spec, and
|
||||||
`AGENTS.md` for local-preview + model-download recipes.
|
`AGENTS.md` for local-preview + model-download recipes.
|
||||||
|
|
||||||
**Model work is moving to the workstation** (owner's call, 2026-08-02). homesrv cannot grow a
|
|
||||||
GPU and the workstation has 16GB of VRAM. So the resident model, STT and TTS become preferred
|
|
||||||
remotes with a floor on homesrv. The workstation is never assumed up. Fall back silently when
|
|
||||||
it would only do the job better. Name the gap when the 1.7B cannot do it at all. The embedder
|
|
||||||
stays on homesrv permanently, because it backs that floor. Read `docs/offload.md` before
|
|
||||||
touching a daemon seam or adding a model caller. Vikunja #483 is the umbrella, #484 to #487
|
|
||||||
are the work.
|
|
||||||
|
|
||||||
## Build & test
|
## Build & test
|
||||||
|
|
||||||
CGO daemons (`mavend`, `mavsttd`, `mavttsd`, `mavenclient`) need the vendored toolchain
|
CGO daemons (`mavend`, `mavsttd`, `mavttsd`, `mavenclient`) need the vendored toolchain
|
||||||
@@ -135,13 +127,11 @@ on in deploy** — this section used to say it was wired `nil`, which stopped be
|
|||||||
Cascade order: `stage0.go` exact-match fast-path → LLM router (when non-nil) → classifier
|
Cascade order: `stage0.go` exact-match fast-path → LLM router (when non-nil) → classifier
|
||||||
fallback. Any LLM error falls through to the classifier so a turn never breaks on the model.
|
fallback. Any LLM error falls through to the classifier so a turn never breaks on the model.
|
||||||
|
|
||||||
Measured on the 77-case RU fixture. **Re-measured 2026-08-02: the classifier scores 68.8%
|
Measured on the 77-case RU fixture (`docs/evals/2026-07-31-model-bakeoff.md`): the classifier scores
|
||||||
full accuracy at p50 16.6µs**, not the 36.8% at p50 31ms that stood here from
|
36.8% full accuracy at p50 31ms; Qwen3-1.7B scores 67.5% intent-only / 72.7% through the
|
||||||
`docs/evals/2026-07-31-model-bakeoff.md`. That older figure predates the stage 0 rules and the
|
cascade at p50 ≈825ms. Accuracy roughly doubled, latency is ~27× worse, and that trade was
|
||||||
seed additions, both of which now score inside the classifier baseline. Qwen3-1.7B scores
|
accepted deliberately. **The ≈2.7s figure that stood here until 2026-08-02 was contention,
|
||||||
77.9% intent-only / 72.7% through the cascade. So the router buys about 4 points of accuracy,
|
not the model.** See `docs/evals/2026-07-31-routing.md` line 61, which measures the LLM router at
|
||||||
not a doubling, and the trade is worth re-arguing rather than assuming. **The ≈2.7s figure
|
|
||||||
that stood here until 2026-08-02 was contention, not the model.** See `docs/evals/2026-07-31-routing.md` line 61, which measures the LLM router at
|
|
||||||
p50 825ms / p95 1.2s / max 3.0s and the full cascade at p50 0.80-1.04s. Do not plan latency
|
p50 825ms / p95 1.2s / max 3.0s and the full cascade at p50 0.80-1.04s. Do not plan latency
|
||||||
work off the bakeoff table. `Confidence: 1.0` used to be hardcoded in `llmrouter.go`, so the LLM
|
work off the bakeoff table. `Confidence: 1.0` used to be hardcoded in `llmrouter.go`, so the LLM
|
||||||
path could never ask for clarification (6/6 refusal cases missed on the fixture) — Vikunja
|
path could never ask for clarification (6/6 refusal cases missed on the fixture) — Vikunja
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The card is an AMD 7900 GRE with 16GB, driven by amdgpu and ROCm. Everything
|
|
||||||
// here reads sysfs and forks nothing: rocm-smi is not even installed on the
|
|
||||||
// workstation, and a poll that costs a subprocess every second is a poll that
|
|
||||||
// gets tuned down until it is useless.
|
|
||||||
|
|
||||||
// gpuProc — one process holding the compute engine.
|
|
||||||
type gpuProc struct {
|
|
||||||
PID int
|
|
||||||
Comm string
|
|
||||||
VRAM int64 // bytes, as the kernel accounts them to this process
|
|
||||||
}
|
|
||||||
|
|
||||||
// probe reads the two sysfs trees the supervisor decides from.
|
|
||||||
//
|
|
||||||
// kfdRoot is /sys/class/kfd/kfd/proc, one directory per ROCm process. The
|
|
||||||
// directory appears when the process initialises HIP, which is well before it
|
|
||||||
// allocates anything large. That is the whole reason this works: the job that
|
|
||||||
// is about to want the card announces itself while it is still starting up,
|
|
||||||
// so we see the contender rather than only the winner of an allocation race.
|
|
||||||
//
|
|
||||||
// drmDev is /sys/class/drm/cardN/device, which reports total and used VRAM for
|
|
||||||
// the card as a whole.
|
|
||||||
type probe struct {
|
|
||||||
kfdRoot string
|
|
||||||
drmDev string
|
|
||||||
}
|
|
||||||
|
|
||||||
// foreign lists every ROCm process that is not ours. selfPID is the supervisor's
|
|
||||||
// llama-server child, or 0 when it is not running.
|
|
||||||
//
|
|
||||||
// An unreadable kfd tree returns no processes and no error. That is deliberate
|
|
||||||
// and it is the safe direction only because startVRAM also has to agree before
|
|
||||||
// anything launches: a supervisor that cannot see the KFD never sees free VRAM
|
|
||||||
// either, because the CPT run holding the card shows up in the drm totals.
|
|
||||||
func (p probe) foreign(selfPID int) []gpuProc {
|
|
||||||
entries, err := os.ReadDir(p.kfdRoot)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var out []gpuProc
|
|
||||||
for _, e := range entries {
|
|
||||||
pid, err := strconv.Atoi(e.Name())
|
|
||||||
if err != nil || pid == selfPID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, gpuProc{
|
|
||||||
PID: pid,
|
|
||||||
Comm: readComm(pid),
|
|
||||||
VRAM: p.procVRAM(e.Name()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// procVRAM sums the per-node vram_* files under one process directory. The
|
|
||||||
// suffix is the KFD topology node id (vram_35881 on this card), so it is
|
|
||||||
// globbed rather than named, and a machine with two cards sums both.
|
|
||||||
func (p probe) procVRAM(pid string) int64 {
|
|
||||||
matches, err := filepath.Glob(filepath.Join(p.kfdRoot, pid, "vram_*"))
|
|
||||||
if err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
var total int64
|
|
||||||
for _, m := range matches {
|
|
||||||
total += readInt(m)
|
|
||||||
}
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
// freeVRAM reports the bytes the card has left. Used only to decide whether to
|
|
||||||
// start: a shortfall here means llama-server would refuse to load anyway. It is
|
|
||||||
// never used to decide to stop, because by the time free VRAM has dropped the
|
|
||||||
// other job has already failed its allocation, which is exactly the outcome
|
|
||||||
// yielding exists to prevent.
|
|
||||||
func (p probe) freeVRAM() int64 {
|
|
||||||
total := readInt(filepath.Join(p.drmDev, "mem_info_vram_total"))
|
|
||||||
used := readInt(filepath.Join(p.drmDev, "mem_info_vram_used"))
|
|
||||||
if total <= 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
if free := total - used; free > 0 {
|
|
||||||
return free
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func readInt(path string) int64 {
|
|
||||||
b, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
n, err := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
// readComm names the contender for the log. The log is the instrument for the
|
|
||||||
// open question in Vikunja #488: whether a process can want this card without
|
|
||||||
// ever registering on the KFD, which a Vulkan or video-decode job would.
|
|
||||||
func readComm(pid int) string {
|
|
||||||
b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "comm"))
|
|
||||||
if err != nil {
|
|
||||||
return "?"
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(string(b))
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// fakeKFD builds the sysfs shape the workstation actually has: one directory
|
|
||||||
// per ROCm process, each holding a vram_<node> file. Sampled from the live box
|
|
||||||
// on 02-08-2026, where the CPT run appeared as proc/478104/vram_35881.
|
|
||||||
func fakeKFD(t *testing.T, vramByPID map[int]int64) string {
|
|
||||||
t.Helper()
|
|
||||||
root := t.TempDir()
|
|
||||||
for pid, vram := range vramByPID {
|
|
||||||
dir := filepath.Join(root, strconv.Itoa(pid))
|
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
f := filepath.Join(dir, "vram_35881")
|
|
||||||
if err := os.WriteFile(f, []byte(strconv.FormatInt(vram, 10)+"\n"), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return root
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestForeignExcludesOurChild(t *testing.T) {
|
|
||||||
root := fakeKFD(t, map[int]int64{478104: 12791693312, 999: 4096})
|
|
||||||
p := probe{kfdRoot: root}
|
|
||||||
|
|
||||||
all := p.foreign(0)
|
|
||||||
if len(all) != 2 {
|
|
||||||
t.Fatalf("with no child running, both processes are foreign, got %d", len(all))
|
|
||||||
}
|
|
||||||
|
|
||||||
ours := p.foreign(999)
|
|
||||||
if len(ours) != 1 || ours[0].PID != 478104 {
|
|
||||||
t.Fatalf("our own llama-server must not count as a contender, got %+v", ours)
|
|
||||||
}
|
|
||||||
if ours[0].VRAM != 12791693312 {
|
|
||||||
t.Errorf("per-process VRAM = %d, want the value from vram_35881", ours[0].VRAM)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// An empty KFD tree is the state that permits a start, so it must read as empty
|
|
||||||
// rather than as an error the caller has to interpret.
|
|
||||||
func TestForeignEmptyAndMissing(t *testing.T) {
|
|
||||||
if got := (probe{kfdRoot: t.TempDir()}).foreign(0); len(got) != 0 {
|
|
||||||
t.Errorf("empty kfd tree: got %d processes, want 0", len(got))
|
|
||||||
}
|
|
||||||
if got := (probe{kfdRoot: "/nonexistent"}).foreign(0); got != nil {
|
|
||||||
t.Errorf("missing kfd tree: got %+v, want nil", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFreeVRAM(t *testing.T) {
|
|
||||||
dev := t.TempDir()
|
|
||||||
write := func(name, v string) {
|
|
||||||
if err := os.WriteFile(filepath.Join(dev, name), []byte(v), 0o644); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The live numbers from the workstation while the CPT run held the card.
|
|
||||||
write("mem_info_vram_total", "17163091968\n")
|
|
||||||
write("mem_info_vram_used", "13396389888\n")
|
|
||||||
p := probe{drmDev: dev}
|
|
||||||
if got, want := p.freeVRAM(), int64(3766702080); got != want {
|
|
||||||
t.Errorf("freeVRAM = %d, want %d", got, want)
|
|
||||||
}
|
|
||||||
if got := (probe{drmDev: "/nonexistent"}).freeVRAM(); got != 0 {
|
|
||||||
t.Errorf("unreadable card reports %d free, want 0 so nothing starts", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// With no model loaded the supervisor must still answer, and it must answer 503
|
|
||||||
// rather than hanging or proxying into a closed port. Maven reads this endpoint
|
|
||||||
// on a timer forever, including while the workstation is busy.
|
|
||||||
func TestHealthAndProxyRefuseWhenNotReady(t *testing.T) {
|
|
||||||
s := &supervisor{run: newRunner("/bin/true", nil, "")}
|
|
||||||
h := s.handler(mustURL(t, "http://127.0.0.1:1"))
|
|
||||||
|
|
||||||
for _, path := range []string{"/health", "/v1/chat/completions"} {
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
|
|
||||||
if w.Code != http.StatusServiceUnavailable {
|
|
||||||
t.Errorf("%s with no model: got %d, want 503", path, w.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustURL(t *testing.T, s string) *url.URL {
|
|
||||||
t.Helper()
|
|
||||||
u, err := url.Parse(s)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return u
|
|
||||||
}
|
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
// mavgpud — the workstation's GPU supervisor.
|
|
||||||
//
|
|
||||||
// It runs on the workstation (an AMD 7900 GRE, 16GB), not on homesrv, and it is
|
|
||||||
// deployed separately from the Maven daemons. Maven does not participate in any
|
|
||||||
// of this and never asks for a start: it reads /health through internal/llm.Pair
|
|
||||||
// and either gets the big model or falls back to the resident 1.7B.
|
|
||||||
//
|
|
||||||
// The rule, from Vikunja #488: keep llama-server loaded whenever the card is
|
|
||||||
// free, unload it when it has been idle too long or when another process needs
|
|
||||||
// the card. Not on demand, because a 7-14B takes tens of seconds to load and a
|
|
||||||
// world question would be answered by a gap every time the card had been quiet.
|
|
||||||
// Not always on, because that holds 16GB against the owner's own jobs.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"flag"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httputil"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"sync/atomic"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type config struct {
|
|
||||||
Listen string `json:"listen"` // what Maven talks to
|
|
||||||
LlamaAddr string `json:"llama_addr"` // where llama-server binds
|
|
||||||
LlamaBin string `json:"llama_bin"`
|
|
||||||
// LlamaArgs must include the flags that bind LlamaAddr. They are passed
|
|
||||||
// through untouched so the model, context size and layer count stay the
|
|
||||||
// owner's business and not this daemon's schema.
|
|
||||||
LlamaArgs []string `json:"llama_args"`
|
|
||||||
|
|
||||||
KFDRoot string `json:"kfd_root"`
|
|
||||||
DRMDevice string `json:"drm_device"`
|
|
||||||
|
|
||||||
Poll duration `json:"poll"`
|
|
||||||
IdleTimeout duration `json:"idle_timeout"`
|
|
||||||
StopGrace duration `json:"stop_grace"`
|
|
||||||
MinFreeVRAM int64 `json:"min_free_vram_bytes"`
|
|
||||||
// EvictAfter and StartAfter are counted in polls, not seconds. Both exist
|
|
||||||
// to damp flapping: a one-tick blip from a short-lived rocm process must
|
|
||||||
// not evict the model, and a card that has just been released must not be
|
|
||||||
// grabbed before the previous job has finished unmapping.
|
|
||||||
EvictAfter int `json:"evict_after_polls"`
|
|
||||||
StartAfter int `json:"start_after_polls"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func defaults() config {
|
|
||||||
return config{
|
|
||||||
Listen: ":8080",
|
|
||||||
LlamaAddr: "127.0.0.1:8081",
|
|
||||||
KFDRoot: "/sys/class/kfd/kfd/proc",
|
|
||||||
DRMDevice: "/sys/class/drm/card1/device",
|
|
||||||
Poll: duration(time.Second),
|
|
||||||
IdleTimeout: duration(15 * time.Minute),
|
|
||||||
StopGrace: duration(20 * time.Second),
|
|
||||||
MinFreeVRAM: 15 << 30,
|
|
||||||
EvictAfter: 2,
|
|
||||||
StartAfter: 5,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// duration lets the config file say "15m" instead of counting nanoseconds.
|
|
||||||
type duration time.Duration
|
|
||||||
|
|
||||||
func (d *duration) UnmarshalJSON(b []byte) error {
|
|
||||||
var s string
|
|
||||||
if err := json.Unmarshal(b, &s); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
v, err := time.ParseDuration(s)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
*d = duration(v)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
path := flag.String("config", "/etc/mavgpud.json", "config file")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
cfg := defaults()
|
|
||||||
b, err := os.ReadFile(*path)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("mavgpud: read config: %v", err)
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(b, &cfg); err != nil {
|
|
||||||
log.Fatalf("mavgpud: parse config: %v", err)
|
|
||||||
}
|
|
||||||
if cfg.LlamaBin == "" {
|
|
||||||
log.Fatal("mavgpud: llama_bin is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
base := "http://" + cfg.LlamaAddr
|
|
||||||
run := newRunner(cfg.LlamaBin, cfg.LlamaArgs, base+"/health")
|
|
||||||
sup := &supervisor{
|
|
||||||
cfg: cfg,
|
|
||||||
probe: probe{kfdRoot: cfg.KFDRoot, drmDev: cfg.DRMDevice},
|
|
||||||
run: run,
|
|
||||||
}
|
|
||||||
sup.touch()
|
|
||||||
|
|
||||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
target, err := url.Parse(base)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("mavgpud: llama_addr: %v", err)
|
|
||||||
}
|
|
||||||
srv := &http.Server{Addr: cfg.Listen, Handler: sup.handler(target)}
|
|
||||||
go func() {
|
|
||||||
log.Printf("mavgpud: listening on %s, model %s", cfg.Listen, cfg.LlamaBin)
|
|
||||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
||||||
log.Fatalf("mavgpud: listen: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
sup.loop(ctx)
|
|
||||||
|
|
||||||
// The card must come back before we do. A supervisor that exits leaving
|
|
||||||
// llama-server holding 14GB is worse than one that never ran.
|
|
||||||
shut, done := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer done()
|
|
||||||
_ = srv.Shutdown(shut)
|
|
||||||
run.stop(time.Duration(cfg.StopGrace))
|
|
||||||
}
|
|
||||||
|
|
||||||
type supervisor struct {
|
|
||||||
cfg config
|
|
||||||
probe probe
|
|
||||||
run *runner
|
|
||||||
|
|
||||||
lastReq atomic.Int64 // unix nanos of the last request Maven sent
|
|
||||||
|
|
||||||
foreignStreak int
|
|
||||||
clearStreak int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *supervisor) touch() { s.lastReq.Store(time.Now().UnixNano()) }
|
|
||||||
|
|
||||||
func (s *supervisor) idle() time.Duration {
|
|
||||||
return time.Since(time.Unix(0, s.lastReq.Load()))
|
|
||||||
}
|
|
||||||
|
|
||||||
// handler serves the two things the workstation exposes.
|
|
||||||
//
|
|
||||||
// /health is answered locally and always, with no GPU cost and no round trip,
|
|
||||||
// because it is the only thing Maven reads and Maven reads it on a timer
|
|
||||||
// forever. Everything else is llama-server's API, reverse-proxied. Proxying
|
|
||||||
// rather than pointing Maven straight at llama-server is what makes the idle
|
|
||||||
// window measurable: the supervisor cannot otherwise know when the model was
|
|
||||||
// last used.
|
|
||||||
func (s *supervisor) handler(target *url.URL) http.Handler {
|
|
||||||
proxy := httputil.NewSingleHostReverseProxy(target)
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !s.run.isReady() {
|
|
||||||
http.Error(w, "model not loaded", http.StatusServiceUnavailable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
|
||||||
})
|
|
||||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !s.run.isReady() {
|
|
||||||
http.Error(w, "model not loaded", http.StatusServiceUnavailable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.touch()
|
|
||||||
proxy.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
return mux
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *supervisor) loop(ctx context.Context) {
|
|
||||||
t := time.NewTicker(time.Duration(s.cfg.Poll))
|
|
||||||
defer t.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-t.C:
|
|
||||||
s.tick(ctx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// tick is the whole decision. Yielding is checked before starting, and presence
|
|
||||||
// on the KFD is what triggers it — not a VRAM threshold. A ROCm process
|
|
||||||
// registers under /sys/class/kfd/kfd/proc when it initialises HIP, before it
|
|
||||||
// allocates, so we see a contender during its startup rather than after it has
|
|
||||||
// already failed to get the memory it wanted.
|
|
||||||
func (s *supervisor) tick(ctx context.Context) {
|
|
||||||
others := s.probe.foreign(s.run.pid())
|
|
||||||
if len(others) > 0 {
|
|
||||||
s.foreignStreak++
|
|
||||||
s.clearStreak = 0
|
|
||||||
} else {
|
|
||||||
s.foreignStreak = 0
|
|
||||||
s.clearStreak++
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.run.running() {
|
|
||||||
s.run.refreshReady(ctx)
|
|
||||||
switch {
|
|
||||||
case s.foreignStreak >= s.cfg.EvictAfter:
|
|
||||||
log.Printf("mavgpud: yielding the card to %s", describe(others))
|
|
||||||
s.run.stop(time.Duration(s.cfg.StopGrace))
|
|
||||||
case s.idle() > time.Duration(s.cfg.IdleTimeout):
|
|
||||||
log.Printf("mavgpud: idle for %s, unloading", s.idle().Round(time.Second))
|
|
||||||
s.run.stop(time.Duration(s.cfg.StopGrace))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.clearStreak < s.cfg.StartAfter {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if free := s.probe.freeVRAM(); free < s.cfg.MinFreeVRAM {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.touch() // the idle clock starts at load, not at the last request before it
|
|
||||||
if err := s.run.start(); err != nil {
|
|
||||||
log.Printf("mavgpud: start llama-server: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// describe names the contenders in the log. This log is the instrument for the
|
|
||||||
// open question in #488: whether polling the KFD misses a job that wants the
|
|
||||||
// card without registering there.
|
|
||||||
func describe(procs []gpuProc) string {
|
|
||||||
out := ""
|
|
||||||
for i, p := range procs {
|
|
||||||
if i > 0 {
|
|
||||||
out += ", "
|
|
||||||
}
|
|
||||||
out += p.Comm
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os/exec"
|
|
||||||
"sync"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// runner owns one llama-server process. Owning it is the point of the daemon:
|
|
||||||
// the workstation cannot keep a 7-14B resident, because that holds 16GB against
|
|
||||||
// the owner's CPT runs, Correx and the manga-recap pipeline. So the thing that
|
|
||||||
// stays up is this, which costs no VRAM, and the model comes and goes under it.
|
|
||||||
type runner struct {
|
|
||||||
bin string
|
|
||||||
args []string
|
|
||||||
// ready is llama-server's own /health, which answers "is a model loaded".
|
|
||||||
// Loading a 7-14B takes tens of seconds, so started is not ready.
|
|
||||||
readyURL string
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
cmd *exec.Cmd
|
|
||||||
ready bool
|
|
||||||
http *http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
func newRunner(bin string, args []string, readyURL string) *runner {
|
|
||||||
return &runner{
|
|
||||||
bin: bin, args: args, readyURL: readyURL,
|
|
||||||
http: &http.Client{Timeout: 2 * time.Second},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// pid is the child's, or 0. The GPU probe needs it to tell our own model apart
|
|
||||||
// from a contender.
|
|
||||||
func (r *runner) pid() int {
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
if r.cmd == nil || r.cmd.Process == nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return r.cmd.Process.Pid
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *runner) running() bool { return r.pid() != 0 }
|
|
||||||
|
|
||||||
// isReady reports the cached readiness. The supervisor loop refreshes it; the
|
|
||||||
// health handler only reads, so answering /health never costs a round trip.
|
|
||||||
func (r *runner) isReady() bool {
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
return r.ready
|
|
||||||
}
|
|
||||||
|
|
||||||
// start launches llama-server. It returns as soon as the process exists, not
|
|
||||||
// when the model is loaded.
|
|
||||||
func (r *runner) start() error {
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
if r.cmd != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
cmd := exec.Command(r.bin, r.args...)
|
|
||||||
// Own process group, so stop kills anything llama-server spawned rather
|
|
||||||
// than leaving it holding VRAM after we have declared the card yielded.
|
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
r.cmd, r.ready = cmd, false
|
|
||||||
log.Printf("mavgpud: started llama-server pid=%d", cmd.Process.Pid)
|
|
||||||
go func() {
|
|
||||||
err := cmd.Wait()
|
|
||||||
r.mu.Lock()
|
|
||||||
r.cmd, r.ready = nil, false
|
|
||||||
r.mu.Unlock()
|
|
||||||
log.Printf("mavgpud: llama-server exited: %v", err)
|
|
||||||
}()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// stop ends llama-server and waits for the VRAM to come back. SIGTERM first so
|
|
||||||
// it unmaps cleanly, SIGKILL after the grace window. Returning before the
|
|
||||||
// process is gone would let the supervisor report a free card while 14GB is
|
|
||||||
// still mapped, which is the one lie that would make yielding useless.
|
|
||||||
func (r *runner) stop(grace time.Duration) {
|
|
||||||
r.mu.Lock()
|
|
||||||
cmd := r.cmd
|
|
||||||
r.ready = false
|
|
||||||
r.mu.Unlock()
|
|
||||||
if cmd == nil || cmd.Process == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pgid := -cmd.Process.Pid
|
|
||||||
_ = syscall.Kill(pgid, syscall.SIGTERM)
|
|
||||||
deadline := time.Now().Add(grace)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
if !r.running() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
}
|
|
||||||
log.Printf("mavgpud: llama-server did not exit in %s, killing", grace)
|
|
||||||
_ = syscall.Kill(pgid, syscall.SIGKILL)
|
|
||||||
}
|
|
||||||
|
|
||||||
// refreshReady asks llama-server whether the model is loaded. Called once per
|
|
||||||
// supervisor tick, never per request.
|
|
||||||
func (r *runner) refreshReady(ctx context.Context) {
|
|
||||||
if !r.running() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ok := false
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, r.readyURL, nil)
|
|
||||||
if err == nil {
|
|
||||||
resp, err := r.http.Do(req)
|
|
||||||
if err == nil {
|
|
||||||
ok = resp.StatusCode == http.StatusOK
|
|
||||||
resp.Body.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
r.mu.Lock()
|
|
||||||
was := r.ready
|
|
||||||
r.ready = ok
|
|
||||||
r.mu.Unlock()
|
|
||||||
if ok && !was {
|
|
||||||
log.Printf("mavgpud: model ready")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-51
@@ -76,56 +76,6 @@
|
|||||||
"snippet_runes": 1500
|
"snippet_runes": 1500
|
||||||
},
|
},
|
||||||
|
|
||||||
"//morning_routines": [
|
|
||||||
"The daily checklist (Vikunja #280). Each item is done when its fact_key",
|
|
||||||
"gets a non-voided fact inside the window, so 'выпил воды' closes water and",
|
|
||||||
"nothing has to be ticked by hand. nudge_at fires once, at the end of the",
|
|
||||||
"window, and only for what is still open. Weekdays empty = every day."
|
|
||||||
],
|
|
||||||
"morning_routines": [
|
|
||||||
{
|
|
||||||
"name": "утро",
|
|
||||||
"window_start": "08:00",
|
|
||||||
"window_end": "11:00",
|
|
||||||
"nudge_at": "10:30",
|
|
||||||
"severity": 1,
|
|
||||||
"items": [
|
|
||||||
{ "key": "medicine", "fact_key": "medicine", "label": "лекарство" },
|
|
||||||
{ "key": "water", "fact_key": "water", "label": "вода" },
|
|
||||||
{ "key": "pets", "fact_key": "pets", "label": "покормить кота" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
|
|
||||||
"//feeds": [
|
|
||||||
"RSS reading (Vikunja #258). Every item lands as a note with source",
|
|
||||||
"rss:<name>, which is also what puts entries in the intake journal that",
|
|
||||||
"/events reads. Only the feed URL leaves the box.",
|
|
||||||
"This is a starting pair, not a curated set — trim or extend it."
|
|
||||||
],
|
|
||||||
"feeds": {
|
|
||||||
"poll_interval": "30m",
|
|
||||||
"max_items": 5,
|
|
||||||
"max_age": "24h",
|
|
||||||
"sources": [
|
|
||||||
{ "name": "lwn", "url": "https://lwn.net/headlines/newrss", "category": "технологии" },
|
|
||||||
{ "name": "archlinux", "url": "https://archlinux.org/feeds/news/", "category": "технологии" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
"//crawl": [
|
|
||||||
"Reading a web page (Vikunja #259). on_demand answers 'посмотри <URL>'.",
|
|
||||||
"No allow_hosts, so any public host he names is readable; private",
|
|
||||||
"addresses are refused unconditionally by internal/webfetch and do not",
|
|
||||||
"need listing. Setting allow_hosts here would also narrow on-demand,",
|
|
||||||
"which is the point of leaving it empty."
|
|
||||||
],
|
|
||||||
"crawl": {
|
|
||||||
"on_demand": true,
|
|
||||||
"timeout": "10s",
|
|
||||||
"max_runes": 4000
|
|
||||||
},
|
|
||||||
|
|
||||||
"digest": {
|
"digest": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"window": "30m",
|
"window": "30m",
|
||||||
@@ -169,7 +119,7 @@
|
|||||||
"timeout": "400ms",
|
"timeout": "400ms",
|
||||||
"rate": 100,
|
"rate": 100,
|
||||||
"max_hosts": 256,
|
"max_hosts": 256,
|
||||||
"enabled": true
|
"enabled": false
|
||||||
},
|
},
|
||||||
|
|
||||||
"nexus": { "url": "http://nexus:9740" },
|
"nexus": { "url": "http://nexus:9740" },
|
||||||
|
|||||||
@@ -79,16 +79,7 @@ services:
|
|||||||
<<: *image
|
<<: *image
|
||||||
# voice.bind is 0.0.0.0:9100 in deploy/mavend.json so mavweb can reach it
|
# voice.bind is 0.0.0.0:9100 in deploy/mavend.json so mavweb can reach it
|
||||||
# cross-container. Verified 2026-07-06.
|
# cross-container. Verified 2026-07-06.
|
||||||
# -ambient-token turns on POST /api/ambient (Vikunja #126): the phone posts
|
|
||||||
# notification text, mavweb keeps only a meeting time. Empty ⇒ no route at
|
|
||||||
# all, which is what a missing MAVEN_AMBIENT_TOKEN gives. The value comes
|
|
||||||
# from the gitignored .env docker compose reads for interpolation, NOT from
|
|
||||||
# an env_file — flags are interpolated before any service env exists.
|
|
||||||
# Weakness worth naming: mavweb takes this as a flag, so it is visible in
|
|
||||||
# `ps` inside this container, unlike the zenmoney and IMAP secrets which are
|
|
||||||
# read from files.
|
|
||||||
command: ["mavweb", "-addr", ":9201", "-voice", "mavend:9100", "-core", "/run/maven/mavend.sock",
|
command: ["mavweb", "-addr", ":9201", "-voice", "mavend:9100", "-core", "/run/maven/mavend.sock",
|
||||||
"-ambient-token", "${MAVEN_AMBIENT_TOKEN:-}",
|
|
||||||
"-nexus", "http://nexus:9740", "-praxis", "http://praxis:8989", "-hexis", "http://hexis:9741"]
|
"-nexus", "http://nexus:9740", "-praxis", "http://praxis:8989", "-hexis", "http://hexis:9741"]
|
||||||
depends_on: [mavend]
|
depends_on: [mavend]
|
||||||
# loopback-only on purpose: /tools defines+executes arbitrary argv and
|
# loopback-only on purpose: /tools defines+executes arbitrary argv and
|
||||||
|
|||||||
-126
@@ -1,126 +0,0 @@
|
|||||||
# Offloading model work to the workstation
|
|
||||||
|
|
||||||
*Last verified: 2026-08-02 @ 5c05163. Living doc: correct it in place, do not append.*
|
|
||||||
|
|
||||||
Owner's call, 2026-08-02. Vikunja #483 is the umbrella. Tasks #484 to #487 are the
|
|
||||||
work, and this file holds the shape and the rules all four must obey.
|
|
||||||
|
|
||||||
## The goal
|
|
||||||
|
|
||||||
homesrv cannot grow a GPU. The workstation has 16GB of VRAM. Move the model work
|
|
||||||
to the workstation and leave homesrv running the logic that must be always-on,
|
|
||||||
deterministic and cheap.
|
|
||||||
|
|
||||||
## Why this is tractable
|
|
||||||
|
|
||||||
The split already exists structurally. `mavsttd` and `mavttsd` are separate
|
|
||||||
daemons that core reaches over a socket, not linked libraries. Moving them off-box
|
|
||||||
is a transport change, not a redesign.
|
|
||||||
|
|
||||||
The microphone is at the workstation, because that is where the owner sits and
|
|
||||||
homesrv is headless. So speech-to-text and the wake word are already on the
|
|
||||||
workstation side by construction. Audio never has to cross the LAN. Only the core
|
|
||||||
turn does.
|
|
||||||
|
|
||||||
## The constraint that shapes everything
|
|
||||||
|
|
||||||
The workstation's GPU is often busy: CPT runs, experiments, Correx, the manga-recap
|
|
||||||
pipeline. It also sleeps. homesrv does not.
|
|
||||||
|
|
||||||
So an offloaded model is never *the* model. It is the preferred one, with a floor
|
|
||||||
on homesrv. That is the shape the cascade already has, where a router error falls
|
|
||||||
through to the classifier.
|
|
||||||
|
|
||||||
## The degradation rule
|
|
||||||
|
|
||||||
Two cases, and the line between them is sharp.
|
|
||||||
|
|
||||||
**Fall back silently** when the workstation model would only do the job *better*:
|
|
||||||
routing, phrasing, a nudge. Falling back costs nothing that exists today, because
|
|
||||||
the resident Qwen3-1.7B is today's production quality. The owner should not be told
|
|
||||||
that his reply was phrased by the smaller model.
|
|
||||||
|
|
||||||
**Name the gap** when the resident model cannot do the job *at all*. A world
|
|
||||||
question that a 1.7B answers by inventing is the case. A wrong answer is worse
|
|
||||||
than "не могу сейчас". This is the rule CLAUDE.md already states for a sibling
|
|
||||||
service being down.
|
|
||||||
|
|
||||||
Nothing in between. A turn never breaks on the workstation being asleep.
|
|
||||||
|
|
||||||
## Admission control, not a scheduler
|
|
||||||
|
|
||||||
There is no GPU arbiter. That is a service with its own failure modes, and nothing
|
|
||||||
here needs work *distributed*. It needs admission control. The workstation
|
|
||||||
advertises free VRAM over a health endpoint, and Maven treats it as one more query
|
|
||||||
source that claims a turn or passes. llama-server also refuses to load when VRAM is
|
|
||||||
short, so the failure is detectable without cooperation from the owner's other
|
|
||||||
jobs.
|
|
||||||
|
|
||||||
The caller must be able to ask "is this peer usable right now" without a turn
|
|
||||||
hanging on a timeout. A dead remote is a normal state, not an error state.
|
|
||||||
`internal/llm.Pair` is that check on the Maven side. A prober caches the answer,
|
|
||||||
so `Available()` is an atomic read and no turn pays for a health check.
|
|
||||||
|
|
||||||
llama-server does not stay up on the workstation. It cannot: a resident 7-14B
|
|
||||||
would hold 16GB against the owner's CPT runs. So a supervisor there owns its
|
|
||||||
lifecycle, keeps it loaded while the card is free, and unloads it on idle or
|
|
||||||
when another process needs the card (owner's call, 2026-08-02, Vikunja #488).
|
|
||||||
|
|
||||||
That supervisor is still not a scheduler, and the distinction is worth holding.
|
|
||||||
It arbitrates nothing between callers. It reports whether it can take work and
|
|
||||||
manages one process to back that answer. Maven never asks it to start anything
|
|
||||||
and never learns that it did.
|
|
||||||
|
|
||||||
## What stays on homesrv, permanently
|
|
||||||
|
|
||||||
The **embedder** (multilingual-e5-small, ONNX, CPU). It backs the classifier, which
|
|
||||||
must answer while the GPU is saturated. It is also cheap enough on CPU that moving
|
|
||||||
it buys nothing. Four callers:
|
|
||||||
|
|
||||||
| Caller | What for |
|
|
||||||
|---|---|
|
|
||||||
| `internal/router/classifier.go` | the routing floor |
|
|
||||||
| `cmd/mavend/actions_query.go` (`queryEmbed`) | memory recall |
|
|
||||||
| `cmd/mavend/feeds.go` | ingest embedding for every RSS item |
|
|
||||||
| `internal/crawl/watch.go` | ingest embedding for every crawled page |
|
|
||||||
|
|
||||||
`internal/speaker` becomes a fifth once it lands.
|
|
||||||
|
|
||||||
## Inventory: what runs a model on homesrv today
|
|
||||||
|
|
||||||
The **resident model** is one llama-server with seven callers:
|
|
||||||
|
|
||||||
| Caller | What for |
|
|
||||||
|---|---|
|
|
||||||
| `cmd/mavend/voicewire.go` | routing |
|
|
||||||
| `cmd/mavend/replier_llm.go` | replies |
|
|
||||||
| `cmd/mavend/tick.go` | digestion worker: `PhraseNudge`, `PhraseReminder` |
|
|
||||||
| `cmd/mavend/capture.go` | capture summarisation (unreachable, see #480) |
|
|
||||||
| `cmd/mavend/mail.go` | mail extraction (off, no IMAP) |
|
|
||||||
| `cmd/mavend/kiwixwire.go` | answering from a Kiwix, search or crawl passage |
|
|
||||||
| `memoryeval.go`, `modelswap.go` | admin and evals |
|
|
||||||
|
|
||||||
Then the embedder above, **whisper.cpp** in `mavsttd`, and **piper** in `mavttsd`.
|
|
||||||
`mavwaked` uses no model at all: an energy-threshold VAD over 30ms frames.
|
|
||||||
|
|
||||||
## Order
|
|
||||||
|
|
||||||
1. **Transport** (#484). Nothing else is possible until a seam can cross a host.
|
|
||||||
`internal/netaddr` landed in PR #92. A seam address now carries its own scheme,
|
|
||||||
and a scheme-less one is still unix. A tcp seam requires a shared token, because
|
|
||||||
the filesystem permission that authenticated the unix socket is gone.
|
|
||||||
2. **The resident model** (#485). Biggest quality delta. A 16GB card runs a 7-14B,
|
|
||||||
which fixes what the 1.7B gets wrong: world knowledge, and the persona the CPT
|
|
||||||
targets. The degradation path is already written and measured, since the
|
|
||||||
classifier scores 68.8% full accuracy at p50 16.6µs on its own.
|
|
||||||
3. **Speech-to-text and text-to-speech** (#486). They gain a real margin, but on
|
|
||||||
quality alone, and both already work.
|
|
||||||
4. **The wake word** (#487). Independent of all of the above.
|
|
||||||
|
|
||||||
## Assumptions
|
|
||||||
|
|
||||||
- The LAN is trusted enough that wireguard is supported but not required (owner's
|
|
||||||
call). What crosses the wire is still his utterances. That is why the tcp seam
|
|
||||||
carries its own token instead of assuming a network boundary.
|
|
||||||
- The workstation is not expected to be up. Every child task must still serve a
|
|
||||||
turn while it is down.
|
|
||||||
+44
-415
@@ -1,62 +1,19 @@
|
|||||||
# QA plan: checking Maven properly
|
# QA plan: checking Maven properly
|
||||||
|
|
||||||
*Last verified: 2026-08-02 @ 20aa2d5. Living doc: correct it in place, do not append.*
|
*Last verified: 2026-08-02 @ 7079a24. Living doc: correct it in place, do not append.*
|
||||||
|
|
||||||
Written 2026-08-01, after the 35-PR stack landed and the box came back up.
|
Written 2026-08-01, after the 35-PR stack landed and the box came back up.
|
||||||
Refreshed 2026-08-02 against the live list, after PRs #85-#90.
|
|
||||||
|
|
||||||
42 of the 50 open Vikunja tasks are `QA:` tasks. They are verification work, not
|
44 of the 50 open Vikunja tasks are `QA:` tasks. They are verification work, not
|
||||||
build work. Most sat unverifiable while Maven was down for 11 days. That
|
build work. Most sat unverifiable while Maven was down for 11 days. That
|
||||||
blocker is gone.
|
blocker is gone.
|
||||||
|
|
||||||
The plan as written on 2026-08-01 named 40 task numbers. Ten open `QA:` tasks were
|
|
||||||
missing and two of the named ones had closed. Every open task now appears below,
|
|
||||||
the eight non-QA ones in the last two sections.
|
|
||||||
|
|
||||||
This plan orders them by what unblocks what. Do sessions 1 and 2 first. Almost everything
|
This plan orders them by what unblocks what. Do sessions 1 and 2 first. Almost everything
|
||||||
downstream assumes the voice loop works, and nobody has confirmed that since
|
downstream assumes the voice loop works, and nobody has confirmed that since
|
||||||
the redeploy.
|
the redeploy.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What the 02-08-2026 run found
|
|
||||||
|
|
||||||
Sessions 1, 2 and 3 all ran. Read these five before picking anything up.
|
|
||||||
|
|
||||||
- **470: a question writes invented knowledge into memory.** Recall then serves
|
|
||||||
it back. `что дальше?` lands on `IntentFact` and stores the model's answer as a
|
|
||||||
`self` fact at confidence 1.00. Two junk rows then claimed seven unrelated
|
|
||||||
world questions through recall, outranking the search leg. A question about the
|
|
||||||
capital of Australia was answered `какая последняя версия языка Go?`. Two bad
|
|
||||||
writes silently disabled world answering, with nothing logged.
|
|
||||||
- **466: a pending clarify is global.** One unanswerable clarify swallowed the
|
|
||||||
next three utterances from three separate sessions. With ntfy, telegram and
|
|
||||||
voice all live, a clarify raised on web chat eats the next telegram message.
|
|
||||||
- **467: spoken task capture is dead.** The router calls the capture marker an
|
|
||||||
`act`, and capture is reachable only from the `note` intent.
|
|
||||||
- **The classifier baseline in this repo was wrong**, and it flattered the
|
|
||||||
router. See session 2 and **464**.
|
|
||||||
- **477: the model swap and the self-update cannot be triggered on this box.**
|
|
||||||
Both are built and both are correct in test. The swap needs a passkey and
|
|
||||||
WebAuthn is unconfigured. `mavupdate` needs to reach a socket that only an
|
|
||||||
in-container uid can open.
|
|
||||||
|
|
||||||
- **479: an unconfigured capability lets the question escape to web search.**
|
|
||||||
Netscan off, asked `какие устройства в сети?`. She answered from the live web
|
|
||||||
with a general article about network hardware. A question about his LAN went to
|
|
||||||
an upstream engine. The crawler fails the same way.
|
|
||||||
|
|
||||||
Twenty-one defects were filed on 02-08-2026: 462 through 482. Six tasks this plan
|
|
||||||
had written off as blocked turned out to be ready to check. All six ran. Every
|
|
||||||
one of them is code-correct and stops at the deploy.
|
|
||||||
|
|
||||||
Three of the five config blockers in **472** were then cleared. The morning
|
|
||||||
routine, ambient ingest, feeds, the crawler and netscan are all live. Two remain,
|
|
||||||
and both are the owner's call: a token for each ecosystem sibling, and seed data
|
|
||||||
in Nexus and Praxis.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Before you start
|
## Before you start
|
||||||
|
|
||||||
Two things bite anyone running these checks on homesrv.
|
Two things bite anyone running these checks on homesrv.
|
||||||
@@ -79,75 +36,45 @@ Nothing here has been confirmed since the redeploy, and everything else assumes
|
|||||||
it works. Do this first.
|
it works. Do this first.
|
||||||
|
|
||||||
Closes or advances: **44** (conversation), **45** (text chat), **287** (voice
|
Closes or advances: **44** (conversation), **45** (text chat), **287** (voice
|
||||||
session quality), **321** steps 3-5 (quiet mode), **288** (STT golden audio).
|
session quality), **321** steps 3-5 (quiet mode), **288** (STT fixtures).
|
||||||
|
|
||||||
**288 is not blocked.** The fixtures are committed under `cmd/mavsttd/testdata/`
|
|
||||||
and `make test-stt-golden` runs today. This plan said otherwise until 02-08-2026.
|
|
||||||
|
|
||||||
Steps 1 and 3-6 were run on 02-08-2026 and pass. Steps 2 and 7-9 still need a
|
|
||||||
person at the box, because they need a microphone or a nudge to arrive.
|
|
||||||
|
|
||||||
Steps 1 and 3-6 do not need a browser. `POST /api/chat` takes a form-encoded
|
|
||||||
`text=` field and a cookie jar, and answers with the rendered `/chat` page:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
curl -s --noproxy '*' -c jar -b jar -L -X POST \
|
|
||||||
http://127.0.0.1:9201/api/chat --data-urlencode 'text=привет'
|
|
||||||
```
|
|
||||||
|
|
||||||
Parse the whole page, not the last text node. The page carries nav and footer
|
|
||||||
text. A naive tail of the Cyrillic nodes returns the wrong string, which makes
|
|
||||||
turns look misaligned when they are not.
|
|
||||||
|
|
||||||
1. Open `http://127.0.0.1:9201/chat` and hold a short conversation in Russian.
|
1. Open `http://127.0.0.1:9201/chat` and hold a short conversation in Russian.
|
||||||
Watch for three things: she answers in feminine forms (`рада`, `поняла`), she
|
Watch for three things: she answers in feminine forms (`рада`, `поняла`), she
|
||||||
says `ты` and never `вы`, and no pet names appear.
|
says `ты` and never `вы`, and no pet names appear.
|
||||||
**Passes** (02-08-2026, five turns): `я рада`, `поняла`, `помогла`,
|
|
||||||
`проверила`, `записала`, `грустна`, `ты` throughout, no pet names.
|
|
||||||
2. Press push-to-talk on `/dash`. Say `привет`. Confirm a spoken reply comes
|
2. Press push-to-talk on `/dash`. Say `привет`. Confirm a spoken reply comes
|
||||||
back. This is the only check that covers mic to STT to core to TTS to
|
back. This is the only check that covers mic to STT to core to TTS to
|
||||||
speaker as one path. It is also the path the eleven-day outage most likely
|
speaker as one path. It is also the path the eleven-day outage most likely
|
||||||
broke.
|
broke.
|
||||||
3. Say `тихий режим`. Expect `тихий режим включён. буду реже напоминать.` **Passes.**
|
3. Say `тихий режим`. Expect `тихий режим включён. буду реже напоминать.`
|
||||||
4. Say `выключи тихий режим`. Expect `тихий режим выключен.` Negation must win. **Passes.**
|
4. Say `выключи тихий режим`. Expect `тихий режим выключен.` Negation must win.
|
||||||
5. Say `в комнате тихо`. Quiet mode must NOT flip. Confirm on `/history` that no
|
5. Say `в комнате тихо`. Quiet mode must NOT flip. Confirm on `/history` that no
|
||||||
`quiet_hours` fact was written. **Passes**: no row written. She answers `пока
|
`quiet_hours` fact was written.
|
||||||
не умею отвечать на этот вопрос.`, so it lands on `IntentSystem` with no arm.
|
|
||||||
6. Say `включи режим тишины`, then `сделай потише`. Both must flip quiet mode
|
6. Say `включи режим тишины`, then `сделай потише`. Both must flip quiet mode
|
||||||
on. These are the noun form and the comparative, added 01-08-2026. **Both pass.**
|
on. These are the noun form and the comparative, added 01-08-2026.
|
||||||
7. Wait for a nudge, then say `потом` within twenty minutes. Expect `хорошо,
|
7. Wait for a nudge, then say `потом` within twenty minutes. Expect `хорошо,
|
||||||
вернусь к этому позже.` and the nudge row on `/notifications` reading
|
вернусь к этому позже.` and the nudge row on `/notifications` reading
|
||||||
`snoozed`. Say `потом` again with nothing pending: it must route as an
|
`snoozed`. Say `потом` again with nothing pending: it must route as an
|
||||||
ordinary utterance, not be swallowed.
|
ordinary utterance, not be swallowed.
|
||||||
8. Wait for the water nudge, then say `выпил воды`. Expect the ordinary fact
|
8. Wait for the water nudge, then say `выпил воды`. Expect the ordinary fact
|
||||||
reply and nothing extra. She must not congratulate you. Check
|
reply and nothing extra — she must not congratulate you. Check
|
||||||
`/notifications`: the row reads `acted`. Then trigger another nudge and say
|
`/notifications`: the row reads `acted`. Then trigger another nudge and say
|
||||||
`готово`. Expect `отлично, отметила.` and the same outcome.
|
`готово`; expect `отлично, отметила.` and the same outcome.
|
||||||
9. Note anything where she is slow, cuts off, or talks over herself. That is
|
9. Note anything where she is slow, cuts off, or talks over herself. That is
|
||||||
287's whole content and it has no written acceptance criteria yet.
|
287's whole content and it has no written acceptance criteria yet.
|
||||||
**First evidence, in text** (02-08-2026): nothing breaks, but answers wander
|
|
||||||
and stitch unrelated topics. Asked whether he should move flats, she opened
|
|
||||||
with the weather. That is 287, and it is a phrasing problem, not a loop problem.
|
|
||||||
|
|
||||||
**The wake path cannot be checked as deployed.** `mavwaked` and `mavenclient`
|
**319 is fixed** (01-08-2026). Single-word Russian utterances no longer come
|
||||||
appear in no compose file and run as no host process. Step 2 covers only
|
|
||||||
push-to-talk, from `/dash` through mavsttd and mavttsd. Wake word and VAD
|
|
||||||
are untested by construction. Decide whether they belong in compose or on a
|
|
||||||
client machine, and say which in the deploy docs. Tracked as **463**.
|
|
||||||
|
|
||||||
**319's single-token bug is fixed** (01-08-2026). Single-word Russian utterances no longer come
|
|
||||||
back as `не совсем поняла — можешь переформулировать?`. `привет` and `поужинал`
|
back as `не совсем поняла — можешь переформулировать?`. `привет` and `поужинал`
|
||||||
both pass now: `thinSingleToken` spares social singles and any token carrying a
|
both pass now: `thinSingleToken` spares social singles and any token carrying a
|
||||||
verb ending, and only thins a bare nominal like `вода`. A one-word utterance that
|
verb ending, and only thins a bare nominal like `вода`. If a one-word utterance
|
||||||
still gets clarified in this session is a new case for the lexicon, not the old bug.
|
still gets clarified during the smoke test, that is a new case for the lexicon,
|
||||||
|
not the old bug.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Session 2: measurement (half a day, mostly waiting)
|
## Session 2: measurement (half a day, mostly waiting)
|
||||||
|
|
||||||
Closes or advances: **320** items 2-4, **278** (make the eval lab routine).
|
Closes or advances: **320** items 2-4, **278** (make the eval lab routine),
|
||||||
Also **248** (memory evaluation), **319** (the margin gate) and **323** (the
|
**319** (gate recalibration).
|
||||||
startup timeout arm).
|
|
||||||
|
|
||||||
The resident llama-server cannot be reached by the eval harness. It binds
|
The resident llama-server cannot be reached by the eval harness. It binds
|
||||||
`--host 127.0.0.1 --port 0` inside the container, so the port is kernel-assigned
|
`--host 127.0.0.1 --port 0` inside the container, so the port is kernel-assigned
|
||||||
@@ -172,67 +99,14 @@ make eval-recall
|
|||||||
|
|
||||||
A large miss against 72.7% means the deploy differs from the bench harness.
|
A large miss against 72.7% means the deploy differs from the bench harness.
|
||||||
|
|
||||||
**Run on 02-08-2026 @ af9d213. The deploy matches the bench.** `eval-models`
|
Two things to decide while the numbers are in front of you:
|
||||||
scored 56 of 77: 72.7% full, 77.9% intent-only, 2 false clarifies and 1 missed.
|
|
||||||
That is the recorded figure to the decimal, and calendar sat at 2 of 2, so the
|
|
||||||
stage 0 agenda rules hold. `eval-phrasing` scored 21 of 27 on the talk fixture
|
|
||||||
against a recorded 20, and the 15 nudge templates passed every check.
|
|
||||||
|
|
||||||
Two numbers in this repo were wrong, and both flattered the resident model.
|
- **319's gate recalibration.** The single-token rule needs narrowing or
|
||||||
|
dropping. This needs your judgement, not a threshold sweep. The fixture and the
|
||||||
- **The classifier is not 36.8% and not 31ms.** `make eval-router` reports
|
daemon disagree about what is correct on two of the three false clarifies.
|
||||||
`classifier+onnx: 53/77 (68.8% full)` at p50 16.6µs. The figure repeated here
|
|
||||||
and in `CLAUDE.md` predates the stage 0 rules and the seed additions. Both now
|
|
||||||
score inside that baseline. The accuracy gap the router buys is
|
|
||||||
roughly 4 points, not 36. Re-argue the trade on the real numbers: **464**.
|
|
||||||
- **Router latency was measured under contention again.** p50 1.126s, p95 1.58s,
|
|
||||||
max 3.24s, against a recorded p50 825ms. The resident model was serving the
|
|
||||||
daemon on the same iGPU throughout. Do not record this as a regression, and do
|
|
||||||
not record it as a measurement either. Stop the stack before timing the router.
|
|
||||||
|
|
||||||
`classifier+hash` scores 19.5%, which is the no-ONNX degraded path and is not the
|
|
||||||
failure floor the deploy uses. Do not quote it as the classifier baseline.
|
|
||||||
|
|
||||||
Then three things to decide while the numbers are in front of you:
|
|
||||||
|
|
||||||
- **319 is done.** 359 gave the LLM path a real confidence signal.
|
|
||||||
`thinSingleToken` was narrowed on 01-08-2026, and agenda questions moved to
|
|
||||||
stage 0. Missed clarify sits at 1 of 6 and false clarifies at 2. Item 2 point 2
|
|
||||||
closed on 02-08-2026: the `make eval-recall` margin sweep is the distribution
|
|
||||||
that was asked for, and `0.008` sits at the knee.
|
|
||||||
|
|
||||||
| delta | answered | false recall |
|
|
||||||
|---|---|---|
|
|
||||||
| 0.005 | 18/27 | 2/5 |
|
|
||||||
| **0.008** | **18/27** | **1/5** |
|
|
||||||
| 0.010 | 16/27 | 1/5 |
|
|
||||||
|
|
||||||
It removes four of five false recalls at no cost in answers, and the next step
|
|
||||||
costs two answers for nothing. The hand-picked value survives on evidence.
|
|
||||||
- **278's real ask** is making the eval lab routine rather than building it. It
|
- **278's real ask** is making the eval lab routine rather than building it. It
|
||||||
is built. Decide whether it runs on a timer, on every merge, or on demand, and
|
is built. Decide whether it runs on a timer, on every merge, or on demand, and
|
||||||
the task can close.
|
the task can close.
|
||||||
- **248** is the memory evaluation loop. It ships, it writes notes, and it cannot
|
|
||||||
speak. `make eval-recall` covers the retrieval half. The open question is whether
|
|
||||||
a written evaluation nobody reads is worth the tick.
|
|
||||||
|
|
||||||
**323 is down to one check.** PR #90 covered the spawn path and took phraser
|
|
||||||
coverage to 76.9%. Only the 60s startup timeout arm is untested, because testing it
|
|
||||||
needs a `StartupTimeout` field on `Config` rather than a test-only hack. While you
|
|
||||||
are on the box, time a cold 1.7B load off spinning disk. If it runs near 60s, the
|
|
||||||
default is too tight and the field earns itself twice.
|
|
||||||
|
|
||||||
Warm, it is nowhere near. A second llama-server answered `/health` 1.8s after
|
|
||||||
launch at `n_ctx 4096` on 02-08-2026. That is page cache, so it does not settle
|
|
||||||
the question. A cold read needs a cache drop, which needs root.
|
|
||||||
|
|
||||||
**`CheckFeminine` has a false positive.** On 02-08-2026 it failed
|
|
||||||
`query-notes-do-not-answer` for `ты заплатил`, calling it masculine
|
|
||||||
self-reference. Masculine second person is correct, because the owner is male.
|
|
||||||
The check matches a masculine
|
|
||||||
past-tense verb before `за` without confirming the subject is `я`. Fix it in
|
|
||||||
`internal/phraser/eval/checks.go` before trusting a phrasing score to the case.
|
|
||||||
The real talk-fixture score on that run is 22 of 27, not 21. Tracked as **462**.
|
|
||||||
|
|
||||||
Item 4 of **320** needs a permission I do not have. Kill the `llama-server`
|
Item 4 of **320** needs a permission I do not have. Kill the `llama-server`
|
||||||
pid under `maven-mavend-1`, post a turn, and confirm it still completes
|
pid under `maven-mavend-1`, post a turn, and confirm it still completes
|
||||||
@@ -241,269 +115,47 @@ check that the failure floor catches a mid-session model death.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Session 3: the interaction batch (a day, or five sittings)
|
## Session 3: the interaction batch (a day, or three sittings)
|
||||||
|
|
||||||
These need real use rather than a command, grouped by what one sitting covers.
|
These need real use rather than a command, grouped by what one sitting covers.
|
||||||
|
|
||||||
**Morning and delivery** (**280**, **281**, **128**, **282**, **283**, **285**):
|
**Morning and delivery** (**280**, **281**, **128**, **282**): open `/morning`,
|
||||||
open `/morning`, walk the seven required behaviours, then check the four
|
walk the seven required behaviours, then check the four interruption outcomes
|
||||||
interruption outcomes and the digest gap. **282** needs the `desk_active` script
|
and the digest gap. **282** needs the `desk_active` script enabled on the desk
|
||||||
enabled on the desk PC first, which is **15** and needs you at that machine.
|
PC first, which is **15** and needs you at that machine.
|
||||||
**283** is the event intake envelope every reach shares, so a delivery check
|
|
||||||
exercises it whether you name it or not. **285** is not verification: the bridge
|
|
||||||
framework works and the remaining ask is more adapters. Decide which reach comes
|
|
||||||
next, or park it.
|
|
||||||
|
|
||||||
Run 02-08-2026. **280 is blocked.** No morning routine is configured (**472**).
|
|
||||||
`morning.Item` also has no required-versus-optional field, so behaviour 1 cannot
|
|
||||||
hold whatever you configure (**473**). **281's digest gap is closed**, and
|
|
||||||
its presence rule passes on inspection. Three of its five items need traffic the
|
|
||||||
box has not had. **283 is blocked**: nothing feeds the intake journal. **128
|
|
||||||
found the worst defect of the whole session, see below.**
|
|
||||||
|
|
||||||
Three of 472's five blockers were cleared the same day, in `deploy/mavend.json`
|
|
||||||
and `docker-compose.yml`.
|
|
||||||
|
|
||||||
- A `morning_routines` block, one routine `утро` 08:00-11:00 with medicine,
|
|
||||||
water and pets. It is live: the dispatcher logged `dropped morning:утро (sev1,
|
|
||||||
presence=away)`, so the plan builds and the nudge is proposed. 280's
|
|
||||||
behaviours and 128 step 11 are checkable now. 473 still stands.
|
|
||||||
- `-ambient-token` on mavweb, value in a gitignored `/.env` that docker compose
|
|
||||||
reads for interpolation. `/api/ambient` answers 401 without the token and 201
|
|
||||||
with it, storing `calendar_event_20260802_Standup`. 283 step 5 and 128 step 8
|
|
||||||
are unblocked. The token is a flag, so it shows in `ps` inside that container.
|
|
||||||
The zenmoney and IMAP secrets are read from files instead. Ingest also
|
|
||||||
reads the notification's wall clock as UTC and stores a 14:30 meeting at 18:30
|
|
||||||
(**482**).
|
|
||||||
- `feeds` (two sources), `crawl.on_demand` and `netscan.enabled`. The intake
|
|
||||||
journal now fills: `/events` holds `scan:lan` and `ambient:notif` rows.
|
|
||||||
|
|
||||||
Two are not mine to clear. No sibling has a `token` in `deploy/mavend.json`, so
|
|
||||||
273 steps 6 and 8 need a credential decision. Nexus has no entities and Praxis no
|
|
||||||
attention items, so 272 step 3 needs seed data whose content is the owner's call.
|
|
||||||
|
|
||||||
For **285**, two facts bear on the choice. Synapse is already running on this box
|
|
||||||
and healthy, so a Matrix reach has a live target and needs no new service. And
|
|
||||||
mavweb is already a PWA with a service worker, which 285 itself calls the highest
|
|
||||||
value adapter left. Today's reaches are ntfy, telegram and voice.
|
|
||||||
|
|
||||||
**Query sources** (**258**, **286**): ask her something the RSS feeds answer and
|
|
||||||
something only a ZIM answers, with the search block on. Live search leads and the
|
|
||||||
ZIMs are the fallback since 02-08-2026. **286**'s remaining half is doc and
|
|
||||||
git ingestion, which is build work, not a check.
|
|
||||||
|
|
||||||
**Do not read `/trace` for this.** `/trace` is the nudge-rule trace: rule,
|
|
||||||
severity, predicate, gate, selected. No query-source field exists anywhere in the
|
|
||||||
codebase. The only evidence of which query source claimed a turn is the
|
|
||||||
`voice: search:` and `voice: kiwix:` lines in `docker compose logs mavend`
|
|
||||||
(`actions_query.go:589` and `:660`).
|
|
||||||
|
|
||||||
Run 02-08-2026, 20 turns. **Search leads and the personal boundary holds.** Every
|
|
||||||
world question that reached the boundary was claimed by search. All three
|
|
||||||
personal questions produced no search and no kiwix line at all.
|
|
||||||
|
|
||||||
The rest of this sitting went badly. **Kiwix has zero live coverage.** SearXNG
|
|
||||||
returns four results for everything, including two invented nonsense terms. So
|
|
||||||
`querySearch` always claims, and Kiwix is unreachable code as deployed. The ZIM
|
|
||||||
half of the 02-08-2026 decision is unverified. A ZIM answer cannot signal a
|
|
||||||
silent search failure, because a ZIM answer cannot happen.
|
|
||||||
**Ordering defects** in feeds and calendar, plus 258 step 1's utterance not
|
|
||||||
working: **474**. And the sitting independently found stage 2 of **470**.
|
|
||||||
|
|
||||||
**Tasks and calendar** (**129**, **130**, **127**, **126**, **246**): capture a
|
**Tasks and calendar** (**129**, **130**, **127**, **126**, **246**): capture a
|
||||||
task by voice, confirm it lands, check prioritisation ordering is not nonsense.
|
task by voice, confirm it lands, check prioritisation ordering is not nonsense.
|
||||||
**246** (mail reader) also exercises the `IngestMail` rung that moved to
|
**246** (mail reader) also exercises the `IngestMail` rung that moved to
|
||||||
`AuthWrite` this morning.
|
`AuthWrite` this morning.
|
||||||
|
|
||||||
Run 02-08-2026. **129 passes.** The page and the spoken answer agree on ordering.
|
|
||||||
The undistinguished task carries no invented reason on either surface, which is
|
|
||||||
the thing 129 asks for. **130 fails outright** and **127 half fails**:
|
|
||||||
**467**, **469**. **246 cannot be run**: `mavmaild` is commented out in
|
|
||||||
`docker-compose.yml` and there is no `email` block, so nothing in steps 4-13 is
|
|
||||||
reachable. The `IngestMail` rung does sit at `AuthWrite`
|
|
||||||
(`internal/auth/policy.go:96`, asserted in `auth_test.go:421`), verified by
|
|
||||||
reading only.
|
|
||||||
|
|
||||||
**Routines and patterns** (**43**, **46**, **247**, **254**): these need history
|
**Routines and patterns** (**43**, **46**, **247**, **254**): these need history
|
||||||
to detect against. If the database is thin after the outage, they may have
|
to detect against. If the database is thin after the outage, they may have
|
||||||
nothing to propose, which is not a failure. Check `/routines` before
|
nothing to propose, which is not a failure. Check `/routines` before
|
||||||
concluding anything.
|
concluding anything.
|
||||||
|
|
||||||
Run 02-08-2026. The answer is the middle case: **the detector ran and found
|
|
||||||
nothing.** The tick loop is live, and `detectPatterns` is called unconditionally
|
|
||||||
at `cmd/mavend/tick.go:227`. It has run about 25 times since the restart. It
|
|
||||||
finds nothing because the events table is empty upstream of it. Rows land there
|
|
||||||
only from `pattern.Extract` at fact-write time, and `Extract` requires the fact
|
|
||||||
value to match a closed 7-action lexicon. All 200 facts on `/history` are
|
|
||||||
`page_heartbeat`, `netdata_alarm`, `quiet_hours`, `name`, `service_down` and
|
|
||||||
`рост`. Not one lexicon hit, so no event can exist, let alone the four one pair
|
|
||||||
needs. **46 step 5 passes**: `/routines` renders `noticed 0` with the empty state
|
|
||||||
and the hint string.
|
|
||||||
|
|
||||||
Two things block this sitting, and both are build work. The seeding recipe on
|
|
||||||
**43** goes through `sqlite3` and cannot work. And `pattern.Detect` has no
|
|
||||||
minimum-interval floor, so seeding by hand mints a permanent false routine
|
|
||||||
(**468**). Do not try to seed a pattern with four fast chat turns.
|
|
||||||
|
|
||||||
**Ecosystem** (**272**, **273**, **276**): nexus, hexis and praxis are wired and
|
**Ecosystem** (**272**, **273**, **276**): nexus, hexis and praxis are wired and
|
||||||
logged clean at boot.
|
logged clean at boot. **276** is the degraded-mode suite, which means taking
|
||||||
|
siblings down on purpose. Worth doing while you are already in there.
|
||||||
Run 02-08-2026, read-only half. All three answer `/health` 200 and `/ecosystem`
|
|
||||||
lists 18 Hexis capabilities with correct read-only and mutating badges. **272 and
|
|
||||||
273 are blocked on empty data**, not on code. Nexus holds no entities, Praxis
|
|
||||||
holds no attention items, and the Calls panel has never recorded a call. See
|
|
||||||
**472**, and read its warning first. 273's trace fix has never been validated
|
|
||||||
here. An empty Calls panel is exactly what the old bug looked like. The page is
|
|
||||||
`/ecosystem`, not `/siblings`.
|
|
||||||
|
|
||||||
**276 ran 02-08-2026 and the suite is sound.** 17 `TestEcosystem_` cases pass
|
|
||||||
under `-race`, not the 10 the task describes. The mutation check bites: patching
|
|
||||||
the Nexus-error branch of `handleHexisAct` to `return ""` fails
|
|
||||||
`TestEcosystem_MalformedNexusResponseFailsClosed` on the expected line.
|
|
||||||
|
|
||||||
Steps 4 and 6 could not be checked through chat, because no utterance reaches
|
|
||||||
Praxis (**475**). «что требует внимания» routes to `intent=query` and is answered
|
|
||||||
by the search leg, identically whether `ecosystem-praxis-1` is up or stopped. The
|
|
||||||
degraded string never appears because its branch is never entered. Step 5 is
|
|
||||||
blocked the same way: `перезапусти muzick indexer` clarifies on
|
|
||||||
`HasFn:false`, and the router had already rewritten the entity name to
|
|
||||||
`музик индексер` (**476**).
|
|
||||||
|
|
||||||
Both steps were checked on `/ecosystem` instead, which reads Praxis directly.
|
|
||||||
With Praxis stopped the card reads `praxis — unreachable` while Nexus and Hexis
|
|
||||||
keep rendering. On `docker start` the card returns to `nothing needs attention.`
|
|
||||||
with no mavend restart. Independent degradation and recovery both hold.
|
|
||||||
|
|
||||||
**Operations** (**249**, **250**): both ran 02-08-2026. The code is correct and
|
|
||||||
neither lever can be pulled on this box. See **477**.
|
|
||||||
|
|
||||||
**250** passes steps 1, 2, 3, 9 and 10 on the deploy. The capability announces
|
|
||||||
itself. `/models` names the model llama-server reports, not the config filename.
|
|
||||||
Asking her to switch models does nothing. Removing `swap_models` renders `swap
|
|
||||||
not configured`. Step 4's refusal half passes at HTTP 403, and the 403 comes from
|
|
||||||
mavend rather than mavweb. WebAuthn is unconfigured, so the web gate fails open
|
|
||||||
and the wire gate fails closed. Steps 5 to 8 need a passkey assertion nothing on
|
|
||||||
this box can produce. They pass in test: 13 swap cases and 7 page cases covering
|
|
||||||
drain, mid-swap refusal, rollback, failed rollback and the not-owned refusal.
|
|
||||||
|
|
||||||
**249** passes steps 1 and 2. Step 3 stops it. `mavupdate` health-checks over
|
|
||||||
`/run/maven/mavend.sock`, which is `srw------- 1 10001 999` inside a docker
|
|
||||||
volume. The host owner cannot traverse `/var/lib/docker/volumes` and cannot
|
|
||||||
connect to a socket owned by an in-container uid. `mavupdate` assumes a
|
|
||||||
host-installed daemon and the deploy is containers. Do not sudo around this.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Housekeeping (done 02-08-2026, and this section was mostly wrong)
|
## Housekeeping (one sitting, no box needed)
|
||||||
|
|
||||||
This section claimed eleven tasks were not verification work. **Three were not.
|
Four QA tasks will not close no matter how long they sit, because they are
|
||||||
The other eight are.** Every one of the eight has shipped, tested code behind it.
|
gated on something that does not exist:
|
||||||
The error ran one way: it wrote off work that is ready to check. Do not trust a
|
|
||||||
"nothing is built" line in this plan without grepping for the package first.
|
|
||||||
|
|
||||||
Relabelled to `Blocked:`, claim verified:
|
- **125** zenmoney: needs a token you have not minted.
|
||||||
|
- **256** Home Assistant: needs HA configured.
|
||||||
|
- **257** Bluetooth: BLOCKED, no bluez on the box. Says so in the title.
|
||||||
|
- **288** STT golden audio: needs fixtures generated.
|
||||||
|
|
||||||
- **125** zenmoney. `internal/zenmoney/` ships and is tested against a fixture.
|
Relabel these so they stop reading as backlog. They are not verification work
|
||||||
`deploy/zenmoney.token` does not exist and the compose mount is commented out.
|
that is pending, they are work that has not started.
|
||||||
One token unblocks it.
|
|
||||||
- **256** Home Assistant. `internal/smarthome/` ships, the `smarthome` block sits
|
|
||||||
in `deploy/mavend.json` at `enabled: false`, and 8123 and 1883 are closed.
|
|
||||||
- **14** cold-start unlock. The seam is real at `cmd/mavend/main.go:128` and
|
|
||||||
`internal/webauthn/prf.go` is in place. `lockedAPI` is gone, replaced by
|
|
||||||
`Server.Check` in `internal/ipc/server.go`. Gated on an authenticator that
|
|
||||||
implements the WebAuthn PRF extension, which is hardware, not code.
|
|
||||||
|
|
||||||
Left alone, because the claim here was false:
|
Same treatment for the five plan-only tasks (**251** MCP, **252** vision,
|
||||||
|
**253** hearing, **255** speaker recognition, **259** crawler). A `QA:` prefix on
|
||||||
- **284** simulator. `cmd/mavend/simulator_test.go`, three scenarios under
|
a plan is misleading.
|
||||||
`cmd/mavend/testdata/scenarios/`, and a `simulate` target at `Makefile:98`.
|
|
||||||
**Run 02-08-2026: all three scenarios pass**, plus the determinism and
|
|
||||||
backwards-step guards. One defect found, see below.
|
|
||||||
- **288** STT golden audio. Four WAVs and `golden_v1.json` are committed under
|
|
||||||
`cmd/mavsttd/testdata/`, the make targets exist, and `models/stt/ggml-small.bin`
|
|
||||||
is on the box. Session 1 lists 288 as blocked on fixtures, which is wrong.
|
|
||||||
**Run 02-08-2026: all four pass**, WER at or under ceiling with no drift.
|
|
||||||
|
|
||||||
| fixture | transcript | WER | ceiling |
|
|
||||||
|---|---|---|---|
|
|
||||||
| ru_reminder | `Напомни мне через час позвонить маме.` | 0.00 | 0.10 |
|
|
||||||
| ru_fact | `А отметь, что я выпил воды.` | 0.20 | 0.25 |
|
|
||||||
| ru_query | `Что у меня сегодня по календарю?` | 0.00 | 0.10 |
|
|
||||||
| en_act | `Restart the web server and check the disk space.` | 0.00 | 0.10 |
|
|
||||||
|
|
||||||
That also settles a session 1 worry indirectly: whisper.cpp works on Vulkan
|
|
||||||
after the redeploy. Only the mic and the wake path remain unproven.
|
|
||||||
|
|
||||||
**The simulator routes with an empty seed set.** Every `make simulate` run logs
|
|
||||||
`loaded 0 seed examples from models/seeds`, seven times per scenario. The test
|
|
||||||
runs from `cmd/mavend`, and the seed path is relative to the repo root. The
|
|
||||||
scenarios still pass, which means they pass without the classifier having any
|
|
||||||
seeds to match against. Whatever 284 is proving, it is not proving the routing
|
|
||||||
the deploy runs. Fix the path before trusting a green simulator.
|
|
||||||
- **257** Bluetooth. The bluez half is genuinely absent. The LAN-scan half shipped
|
|
||||||
(`internal/netscan/`), and steps 1-9 run today. Only step 10 is Bluetooth, so
|
|
||||||
relabelling the whole task would bury real pending work.
|
|
||||||
- **251** MCP, **253** hearing, **259** crawler. All three ship
|
|
||||||
(`internal/mcp/`, `internal/capture/`, `internal/crawl/`) with no external gate.
|
|
||||||
Fully checkable. `259`'s step 1 wants no `crawl` block in `deploy/mavend.json`,
|
|
||||||
and there is none, so it is already set up correctly.
|
|
||||||
- **252** vision and **255** speaker recognition. Both ship. Each is blocked only
|
|
||||||
on a model download: a vision gguf with mmproj, and a speaker embedding model.
|
|
||||||
Neither is present under `/mnt/hdd1`. Their refusal-path steps run today.
|
|
||||||
|
|
||||||
So the honest split is three blocked on a credential or hardware, two blocked on
|
|
||||||
a download, and six ready to check. That is roughly a session of real QA this
|
|
||||||
plan had written off as backlog.
|
|
||||||
|
|
||||||
**All six ran on 02-08-2026.** Every one of them is code-correct and stops at the
|
|
||||||
deploy. The pattern repeats often enough to be the headline: the packages pass,
|
|
||||||
and the box cannot reach them.
|
|
||||||
|
|
||||||
**251, MCP.** Steps 1, 2, 3, 4 and 13 pass. Package tests green under `-race`.
|
|
||||||
Off-by-default is clean, and the SSRF refusal is exact: without `allow_private`
|
|
||||||
the log reads `refusing to connect to a private address: 127.0.0.1` and `/tools`
|
|
||||||
shows the server down with zero proposals. Steps 5 to 12 are blocked. `ss -lntp`
|
|
||||||
shows the Vikunja MCP server on `127.0.0.1:9100` only, so no container reaches it
|
|
||||||
at any address (**478**). `allow_private` does work, measured both ways.
|
|
||||||
|
|
||||||
**253, hearing.** Steps 1, 2 and 17 pass. `internal/capture` covers 90.3%. Steps
|
|
||||||
7 to 16 are blocked on something nobody can work around: no shipped client calls
|
|
||||||
`CaptureStart`. There is no `cmd/mavheard`, no mavweb route, and `mavenclient`
|
|
||||||
never calls it (**480**). Two of its QA steps are also stale.
|
|
||||||
|
|
||||||
**257, netscan.** Steps 2, 3 and 9 pass at unit level. Step 1 fails. Steps 4 to 8
|
|
||||||
need the block enabled. Step 10 is Bluetooth and stays skipped.
|
|
||||||
|
|
||||||
**259, crawler.** Steps 1 and 15 pass. Step 2 fails. Steps 3 to 14 need a `crawl`
|
|
||||||
block that nobody has written.
|
|
||||||
|
|
||||||
Both were configured later the same day, and both work. `netscan.enabled: true`
|
|
||||||
answers `какие устройства в сети?` with `нашла 3 устройства, из них 2 с вебом, 2 с
|
|
||||||
ssh. список записала.` and the scan lands in the intake journal as `scan:lan`.
|
|
||||||
`crawl.on_demand: true` answers `посмотри https://lwn.net — что там пишут?` from
|
|
||||||
the real page. So **479** is one defect, not the routing defect it was filed as.
|
|
||||||
An unconfigured capability declines its own turn instead of naming the gap.
|
|
||||||
Nothing is wrong with the routing.
|
|
||||||
|
|
||||||
257 step 1 and 259 step 2 fail the same way and share a task (**479**). An
|
|
||||||
unconfigured capability does not name the gap, so the question escapes to web
|
|
||||||
search. `какие устройства в сети?` was answered with a general article about
|
|
||||||
network hardware. That is his LAN going to an upstream engine.
|
|
||||||
|
|
||||||
**252 vision and 255 speaker.** Both confirmed blocked. The disk claim was
|
|
||||||
re-verified rather than taken on trust: 16 text-only ggufs under `/mnt/hdd1`, no
|
|
||||||
mmproj and no speaker embedding model. Everything not needing the model passes,
|
|
||||||
including the two refusals that matter. `TestNewLocalRefusesNonPrivateEndpoints`
|
|
||||||
rejects `https://api.openai.com`, and forget really deletes
|
|
||||||
(`internal/store/memory.go:145` is a real `DELETE`, not a tombstone). Vision is
|
|
||||||
19/19, speaker 22/22, media 16/16.
|
|
||||||
|
|
||||||
**470 got worse.** Both poisoned facts show `voided` on `/history`, and the
|
|
||||||
defect survives. Re-measured at 15:42, after four restarts: `почему небо синее?`
|
|
||||||
still answers `какая последняя версия языка Go?` with no `search:` line. What
|
|
||||||
comes back is the question he typed, not the value the fact held. So the poison
|
|
||||||
is a vector in the memory index, and `revert` does not remove it. There is
|
|
||||||
currently no documented way to repair a poisoned box.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -519,39 +171,16 @@ Not QA. These are blocked on a decision or a credential only you have.
|
|||||||
| 355 | Deploy the Hexis auth change. Was blocked on Maven being under construction, which it no longer is. The client half is vendored and wired. |
|
| 355 | Deploy the Hexis auth change. Was blocked on Maven being under construction, which it no longer is. The client half is vendored and wired. |
|
||||||
| 357 | Decide whether entity-existence validation is the permanent target guard or whether blessing lands in Nexus. |
|
| 357 | Decide whether entity-existence validation is the permanent target guard or whether blessing lands in Nexus. |
|
||||||
| 275 | Hexis native API and MCP parity. |
|
| 275 | Hexis native API and MCP parity. |
|
||||||
| — | Decide on `-require-stepup`. Making it the default needs WebAuthn configured first, or it locks you out of your own admin surfaces. |
|
| — | Decide on `-require-stepup`. Making it the default needs WebAuthn configured first, or it locks you out of your own admin surfaces. See **317**. |
|
||||||
|
| — | Three nginx sites bind wildcard `:80` (`acme.conf`, `matrix`, `panel`), so the ecosystem's bind-level protection is not in effect and `allow`/`deny` is carrying it alone. See **354**. |
|
||||||
317 and 354 closed on 01-08-2026. The step-up gate now covers `POST /api/chat` and
|
|
||||||
`/routines`, and the nginx template is locked down with a `maven.<domain>` block for
|
|
||||||
mavweb. The `-require-stepup` default is still your call.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Not this repo
|
|
||||||
|
|
||||||
Two open tasks sit on the Maven board and are not Maven work. Move them or note
|
|
||||||
where they land, so the board stops reading as 50 things Maven owes.
|
|
||||||
|
|
||||||
- **358** replace the rowid execution cursor with a real seq column. This is Hexis,
|
|
||||||
and it must land before any execution retention or pruning does.
|
|
||||||
- **362** mirror the router prompt reorder into the relabelling prompt. This is the
|
|
||||||
training workspace, enforced by `llm/check_prompt_parity.py` there, not here.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Suggested order
|
## Suggested order
|
||||||
|
|
||||||
1. Session 1. If the voice loop is broken, nothing else matters.
|
1. Session 1. If the voice loop is broken, nothing else matters.
|
||||||
2. The `-require-stepup` and Kuma decisions. Five minutes, and it unblocks **16**.
|
2. The `-require-stepup` and Kuma decisions. Five minutes, unblocks **317** fully
|
||||||
3. Session 2. **Run on 02-08-2026.** The numbers came back worse for the router
|
and **16**.
|
||||||
than the docs claimed. The classifier is 68.8%, not 36.8%, and 16.6µs, not
|
3. Session 2. The numbers tell you whether the router is worth its 90x latency.
|
||||||
31ms. The router buys about 4 points of accuracy for four orders of magnitude
|
|
||||||
of latency. Whether that still earns its place is now an open question.
|
|
||||||
4. Housekeeping. Cheap, and it makes the remaining backlog honest.
|
4. Housekeeping. Cheap, and it makes the remaining backlog honest.
|
||||||
5. Session 3, split whichever way suits you. All five sittings ran on
|
5. Session 3, split whichever way suits you.
|
||||||
02-08-2026. Read the per-sitting notes before repeating any of them.
|
|
||||||
|
|
||||||
The next thing to fix is not in this plan. Four defects say the same sentence:
|
|
||||||
a capability is built and no utterance reaches it. **466** (a clarify is global),
|
|
||||||
**467** (capture is act-routed), **475** (attention is act-routed), **476** (the
|
|
||||||
router rewrites entity names). Routing is where the work is.
|
|
||||||
|
|||||||
@@ -1,193 +0,0 @@
|
|||||||
package llm
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Pair — a preferred model on another host, with the resident one as the floor.
|
|
||||||
//
|
|
||||||
// homesrv cannot grow a GPU and the workstation has 16GB of VRAM, so the big
|
|
||||||
// model runs there and the resident Qwen3-1.7B stays here. See docs/offload.md.
|
|
||||||
// The workstation is never assumed up: its GPU is often busy with CPT runs and
|
|
||||||
// the manga-recap pipeline, and the machine sleeps. So the remote is preferred,
|
|
||||||
// never required, and Pair is what makes "preferred" mean something precise.
|
|
||||||
//
|
|
||||||
// This is admission control, not a scheduler. There is no arbiter deciding who
|
|
||||||
// gets the card. A prober asks the remote whether it will take work, caches the
|
|
||||||
// answer, and every request reads that cached answer in nanoseconds. Routing
|
|
||||||
// sits on the hot path at p50 825ms and must never wait on a machine that may
|
|
||||||
// be asleep, so no request ever pays for a health check itself.
|
|
||||||
//
|
|
||||||
// Pair satisfies nothing by itself. Callers pick a method by which half of the
|
|
||||||
// degradation rule they live under:
|
|
||||||
//
|
|
||||||
// - Complete falls back silently. For routing, replies, and nudge phrasing,
|
|
||||||
// where the big model is only better and the 1.7B is today's shipping
|
|
||||||
// quality. He is not told which model phrased his reply.
|
|
||||||
// - CompleteRemote returns ErrRemoteUnavailable instead of falling back. For
|
|
||||||
// a world question, or a long Kiwix or search passage, where a 1.7B
|
|
||||||
// confabulates rather than summarises. A named gap beats an invented
|
|
||||||
// answer.
|
|
||||||
type Pair struct {
|
|
||||||
remote *Client
|
|
||||||
floor *Client
|
|
||||||
|
|
||||||
// up — the cached admission answer, written only by the prober goroutine
|
|
||||||
// and read by every request. Atomic so the read costs nanoseconds and no
|
|
||||||
// request ever contends with the prober.
|
|
||||||
up atomic.Bool
|
|
||||||
|
|
||||||
health string
|
|
||||||
interval time.Duration
|
|
||||||
http *http.Client
|
|
||||||
stop chan struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ErrRemoteUnavailable — the workstation model was required and is not
|
|
||||||
// answering. Callers on the naming half of the degradation rule turn this into
|
|
||||||
// a gap in the reply ("не могу сейчас"), never into a guess from the floor.
|
|
||||||
var ErrRemoteUnavailable = errors.New("llm: workstation model unavailable")
|
|
||||||
|
|
||||||
// ErrNoFloor — a Pair was built with no resident model to fall back to. A
|
|
||||||
// configuration mistake: the floor is the whole point.
|
|
||||||
var ErrNoFloor = errors.New("llm: no floor client")
|
|
||||||
|
|
||||||
// NewPair builds the two-model arrangement. remote may be nil, which is the
|
|
||||||
// unconfigured deploy and must behave exactly as the box behaves today: every
|
|
||||||
// call goes to the floor and nothing probes anything.
|
|
||||||
//
|
|
||||||
// health is the URL the prober asks. llama-server's /health answers "is a model
|
|
||||||
// loaded and ready", which is the useful signal here, because llama-server
|
|
||||||
// refuses to load at all when VRAM is short. That makes a busy card detectable
|
|
||||||
// without any cooperation from the owner's other jobs.
|
|
||||||
func NewPair(remote, floor *Client, health string, interval time.Duration) *Pair {
|
|
||||||
p := &Pair{
|
|
||||||
remote: remote,
|
|
||||||
floor: floor,
|
|
||||||
health: health,
|
|
||||||
interval: interval,
|
|
||||||
http: &http.Client{Timeout: probeTimeout},
|
|
||||||
stop: make(chan struct{}),
|
|
||||||
}
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
// probeTimeout — a remote that cannot answer /health this fast is not going to
|
|
||||||
// serve a turn either. Short on purpose: the prober runs on its own goroutine,
|
|
||||||
// but a slow probe still delays the moment Maven notices the card came back.
|
|
||||||
const probeTimeout = 2 * time.Second
|
|
||||||
|
|
||||||
// Start begins probing. It returns immediately, and the first probe runs before
|
|
||||||
// the first tick so a remote that is already up is used on the first turn
|
|
||||||
// rather than after one interval of falling back. Safe to call with a nil
|
|
||||||
// remote; it does nothing.
|
|
||||||
func (p *Pair) Start(ctx context.Context) {
|
|
||||||
if p.remote == nil || p.health == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
go func() {
|
|
||||||
p.probe(ctx)
|
|
||||||
t := time.NewTicker(p.interval)
|
|
||||||
defer t.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-p.stop:
|
|
||||||
return
|
|
||||||
case <-t.C:
|
|
||||||
p.probe(ctx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop ends the prober. Idempotent.
|
|
||||||
func (p *Pair) Stop() {
|
|
||||||
select {
|
|
||||||
case <-p.stop:
|
|
||||||
default:
|
|
||||||
close(p.stop)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Available reports whether the workstation will take work right now. It reads
|
|
||||||
// a cached flag, so it is safe to call per turn on the hot path. A false answer
|
|
||||||
// is never stale in the direction that matters: the worst case is that Maven
|
|
||||||
// falls back for up to one probe interval after the card frees up.
|
|
||||||
func (p *Pair) Available() bool {
|
|
||||||
return p.remote != nil && p.up.Load()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Pair) probe(ctx context.Context) {
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, probeTimeout)
|
|
||||||
defer cancel()
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.health, nil)
|
|
||||||
if err != nil {
|
|
||||||
p.set(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resp, err := p.http.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
p.set(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
p.set(resp.StatusCode == http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// set records the admission answer and logs only the transitions. A machine
|
|
||||||
// that sleeps every night would otherwise write one line per interval forever.
|
|
||||||
func (p *Pair) set(up bool) {
|
|
||||||
if p.up.Swap(up) == up {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if up {
|
|
||||||
log.Printf("llm: workstation model available at %s", p.health)
|
|
||||||
} else {
|
|
||||||
log.Printf("llm: workstation model unavailable, falling back to the resident model")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Complete runs r on the workstation when it will take work, and on the
|
|
||||||
// resident model otherwise. A remote that fails mid-request falls back too: the
|
|
||||||
// admission answer is a cache and can be one interval out of date, so an error
|
|
||||||
// here is expected rather than exceptional.
|
|
||||||
//
|
|
||||||
// This is the silent half of the degradation rule. It must be indistinguishable
|
|
||||||
// from today's behaviour when the workstation is down.
|
|
||||||
func (p *Pair) Complete(ctx context.Context, r Req) (string, error) {
|
|
||||||
if p.floor == nil {
|
|
||||||
return "", ErrNoFloor
|
|
||||||
}
|
|
||||||
if p.Available() {
|
|
||||||
out, err := p.remote.Complete(ctx, r)
|
|
||||||
if err == nil {
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
// The cached answer was wrong. Correct it now rather than sending the
|
|
||||||
// next request into the same hole, then fall back.
|
|
||||||
p.set(false)
|
|
||||||
}
|
|
||||||
return p.floor.Complete(ctx, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CompleteRemote runs r on the workstation or refuses. It never falls back,
|
|
||||||
// because for a world question the resident 1.7B does not answer worse, it
|
|
||||||
// invents. Callers turn ErrRemoteUnavailable into a named gap.
|
|
||||||
func (p *Pair) CompleteRemote(ctx context.Context, r Req) (string, error) {
|
|
||||||
if !p.Available() {
|
|
||||||
return "", ErrRemoteUnavailable
|
|
||||||
}
|
|
||||||
out, err := p.remote.Complete(ctx, r)
|
|
||||||
if err != nil {
|
|
||||||
p.set(false)
|
|
||||||
return "", errors.Join(ErrRemoteUnavailable, err)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
package llm
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// completionServer stands in for a llama-server. It counts what reached it, so
|
|
||||||
// a test can say which of the two models answered.
|
|
||||||
func completionServer(t *testing.T, reply string, hits *atomic.Int64) *httptest.Server {
|
|
||||||
t.Helper()
|
|
||||||
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
hits.Add(1)
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"` + reply + `"}}]}`))
|
|
||||||
}))
|
|
||||||
t.Cleanup(s.Close)
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
func healthServer(t *testing.T, ok *atomic.Bool) *httptest.Server {
|
|
||||||
t.Helper()
|
|
||||||
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !ok.Load() {
|
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
t.Cleanup(s.Close)
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// waitFor polls until cond holds or the deadline passes. The prober runs on its
|
|
||||||
// own goroutine, so a test has to wait for it rather than assume it has run.
|
|
||||||
func waitFor(t *testing.T, cond func() bool) bool {
|
|
||||||
t.Helper()
|
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
if cond() {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
time.Sleep(5 * time.Millisecond)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// The unconfigured deploy. No remote, no probing, every call to the floor —
|
|
||||||
// exactly what the box does today.
|
|
||||||
func TestNoRemoteGoesToTheFloor(t *testing.T) {
|
|
||||||
var floorHits atomic.Int64
|
|
||||||
floor := completionServer(t, "floor", &floorHits)
|
|
||||||
|
|
||||||
p := NewPair(nil, New(floor.URL, time.Second), "", time.Second)
|
|
||||||
p.Start(context.Background())
|
|
||||||
defer p.Stop()
|
|
||||||
|
|
||||||
if p.Available() {
|
|
||||||
t.Fatal("a Pair with no remote reports available")
|
|
||||||
}
|
|
||||||
out, err := p.Complete(context.Background(), Req{User: "привет"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("complete: %v", err)
|
|
||||||
}
|
|
||||||
if out != "floor" || floorHits.Load() != 1 {
|
|
||||||
t.Fatalf("out = %q, floor hits = %d", out, floorHits.Load())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The workstation is up, so it answers and the resident model is not touched.
|
|
||||||
func TestAvailableRemoteAnswers(t *testing.T) {
|
|
||||||
var remoteHits, floorHits atomic.Int64
|
|
||||||
remote := completionServer(t, "remote", &remoteHits)
|
|
||||||
floor := completionServer(t, "floor", &floorHits)
|
|
||||||
up := &atomic.Bool{}
|
|
||||||
up.Store(true)
|
|
||||||
health := healthServer(t, up)
|
|
||||||
|
|
||||||
p := NewPair(New(remote.URL, time.Second), New(floor.URL, time.Second), health.URL, 20*time.Millisecond)
|
|
||||||
p.Start(context.Background())
|
|
||||||
defer p.Stop()
|
|
||||||
if !waitFor(t, p.Available) {
|
|
||||||
t.Fatal("prober never saw the remote come up")
|
|
||||||
}
|
|
||||||
|
|
||||||
out, err := p.Complete(context.Background(), Req{User: "привет"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("complete: %v", err)
|
|
||||||
}
|
|
||||||
if out != "remote" || floorHits.Load() != 0 {
|
|
||||||
t.Fatalf("out = %q, floor hits = %d", out, floorHits.Load())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The card is busy, so /health refuses and Complete degrades silently. This is
|
|
||||||
// the constraint from 483: the workstation being down is indistinguishable from
|
|
||||||
// today's behaviour.
|
|
||||||
func TestBusyCardFallsBackSilently(t *testing.T) {
|
|
||||||
var remoteHits, floorHits atomic.Int64
|
|
||||||
remote := completionServer(t, "remote", &remoteHits)
|
|
||||||
floor := completionServer(t, "floor", &floorHits)
|
|
||||||
health := healthServer(t, &atomic.Bool{}) // never ok
|
|
||||||
|
|
||||||
p := NewPair(New(remote.URL, time.Second), New(floor.URL, time.Second), health.URL, 20*time.Millisecond)
|
|
||||||
p.Start(context.Background())
|
|
||||||
defer p.Stop()
|
|
||||||
time.Sleep(60 * time.Millisecond)
|
|
||||||
|
|
||||||
out, err := p.Complete(context.Background(), Req{User: "привет"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("complete: %v", err)
|
|
||||||
}
|
|
||||||
if out != "floor" || remoteHits.Load() != 0 {
|
|
||||||
t.Fatalf("out = %q, remote hits = %d", out, remoteHits.Load())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The cached admission answer can be one interval out of date, so a remote that
|
|
||||||
// dies between probes must still not break the turn.
|
|
||||||
func TestRemoteErrorMidRequestFallsBack(t *testing.T) {
|
|
||||||
var floorHits atomic.Int64
|
|
||||||
dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
}))
|
|
||||||
defer dead.Close()
|
|
||||||
floor := completionServer(t, "floor", &floorHits)
|
|
||||||
up := &atomic.Bool{}
|
|
||||||
up.Store(true)
|
|
||||||
health := healthServer(t, up)
|
|
||||||
|
|
||||||
p := NewPair(New(dead.URL, time.Second), New(floor.URL, time.Second), health.URL, time.Hour)
|
|
||||||
p.Start(context.Background())
|
|
||||||
defer p.Stop()
|
|
||||||
if !waitFor(t, p.Available) {
|
|
||||||
t.Fatal("prober never saw the remote come up")
|
|
||||||
}
|
|
||||||
|
|
||||||
out, err := p.Complete(context.Background(), Req{User: "привет"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("complete: %v", err)
|
|
||||||
}
|
|
||||||
if out != "floor" || floorHits.Load() != 1 {
|
|
||||||
t.Fatalf("out = %q, floor hits = %d", out, floorHits.Load())
|
|
||||||
}
|
|
||||||
// The failed request must have corrected the cached answer, so the next
|
|
||||||
// one does not walk into the same hole.
|
|
||||||
if p.Available() {
|
|
||||||
t.Fatal("a failed remote request left the admission answer up")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The naming half of the degradation rule. A world question must not be handed
|
|
||||||
// to the resident model, because it answers by inventing.
|
|
||||||
func TestCompleteRemoteNamesTheGap(t *testing.T) {
|
|
||||||
var floorHits atomic.Int64
|
|
||||||
floor := completionServer(t, "floor", &floorHits)
|
|
||||||
health := healthServer(t, &atomic.Bool{}) // never ok
|
|
||||||
|
|
||||||
p := NewPair(New("http://127.0.0.1:1", time.Second), New(floor.URL, time.Second), health.URL, 20*time.Millisecond)
|
|
||||||
p.Start(context.Background())
|
|
||||||
defer p.Stop()
|
|
||||||
time.Sleep(60 * time.Millisecond)
|
|
||||||
|
|
||||||
if _, err := p.CompleteRemote(context.Background(), Req{User: "почему небо голубое"}); !errors.Is(err, ErrRemoteUnavailable) {
|
|
||||||
t.Fatalf("err = %v, want ErrRemoteUnavailable", err)
|
|
||||||
}
|
|
||||||
if floorHits.Load() != 0 {
|
|
||||||
t.Fatalf("CompleteRemote fell back to the floor %d times", floorHits.Load())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Routing sits on the hot path and must never pay for a health check. Available
|
|
||||||
// reads a cached flag, so it costs no network at all.
|
|
||||||
func TestAvailableDoesNotProbe(t *testing.T) {
|
|
||||||
var probes atomic.Int64
|
|
||||||
health := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
probes.Add(1)
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
defer health.Close()
|
|
||||||
|
|
||||||
p := NewPair(New("http://127.0.0.1:1", time.Second), New("http://127.0.0.1:1", time.Second), health.URL, time.Hour)
|
|
||||||
p.Start(context.Background())
|
|
||||||
defer p.Stop()
|
|
||||||
if !waitFor(t, p.Available) {
|
|
||||||
t.Fatal("prober never ran")
|
|
||||||
}
|
|
||||||
|
|
||||||
before := probes.Load()
|
|
||||||
for range 1000 {
|
|
||||||
p.Available()
|
|
||||||
}
|
|
||||||
if got := probes.Load(); got != before {
|
|
||||||
t.Fatalf("1000 Available calls made %d probes", got-before)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A Pair with no floor is a configuration mistake, and it must say so rather
|
|
||||||
// than silently having nowhere to degrade to.
|
|
||||||
func TestNoFloorIsAnError(t *testing.T) {
|
|
||||||
p := NewPair(nil, nil, "", time.Second)
|
|
||||||
if _, err := p.Complete(context.Background(), Req{User: "привет"}); !errors.Is(err, ErrNoFloor) {
|
|
||||||
t.Fatalf("err = %v, want ErrNoFloor", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user