Compare commits

...

38 Commits

Author SHA1 Message Date
claude eca41798bd mavgpud: turn gemma's thinking off in the chat template (V-489)
Owner's call, 02-08-2026. Without it the 12B spends the reply budget on
reasoning tokens and answers empty at low max_tokens. Verified on the box:
"Столица Франции?" now answers "Париж" with no reasoning_content.
2026-08-02 22:44:10 +04:00
claude cc423567e7 docs: record that contention is KFD presence, not a VRAM threshold (V-489) 2026-08-02 22:29:58 +04:00
claude 8088ef9e00 mavgpud: build it with the rest, and ship the workstation config and unit (V-489)
make build now catches a broken supervisor on homesrv. deploy/mavgpud.json
carries the owner's gemma-4-12b line with the MTP draft model, passed to
llama-server untouched. The unit is a systemd user unit because sudo on the
workstation wants a password; lingering is the one command left to the owner.
2026-08-02 22:29:57 +04:00
kami 666b924d29 Merge pull request 'Run the big model on the workstation, with admission control and the 1.7B as the floor' (#95) from task/488-workstation-a-supervisor-that-keeps-llam into master
Reviewed-on: #95
2026-08-02 17:04:05 +02:00
claude e52c616592 mavgpud: test the probe against the sysfs the workstation actually has (V-488)
The fixtures are the live numbers sampled from the box on 02-08-2026, where the
CPT run held 12.8GB of 16 as proc/478104/vram_35881.

The cases that matter are the ones where a mistake is silent: our own
llama-server counting as a contender, an unreadable card reading as free, and
/health hanging or proxying into a closed port instead of answering 503.
2026-08-02 17:03:56 +02:00
claude 2b97bac51e mavgpud: keep the model loaded while the card is free, yield when it is not (V-488)
The lifecycle rule from Vikunja #488. Not on demand, because a 7-14B takes tens
of seconds to load and a world question would meet a gap every time the card
had been quiet. Not always on, because that is what holds the card.

/health is answered locally and always, so Maven's prober costs nothing and
works while the model is down. Everything else is reverse-proxied to
llama-server, which is what makes the idle window measurable at all.

Yielding is checked before starting, and both transitions are damped by a poll
streak so a short-lived rocm process cannot evict the model.
2026-08-02 17:03:56 +02:00
claude ab42db2b87 mavgpud: read the card from sysfs and own llama-server's lifecycle (V-488)
The workstation cannot keep a 7-14B resident: it would hold 16GB against the
owner's CPT runs, Correx and the manga-recap pipeline. So the process that
stays up costs no VRAM and the model comes and goes under it.

Contention is detected by presence on the KFD, not by a VRAM threshold. A ROCm
process registers under /sys/class/kfd/kfd/proc when it initialises HIP, well
before it allocates, so we see a contender during its startup instead of after
it has already lost an allocation race. rocm-smi is not installed on that box
and a per-second subprocess would get tuned down until useless, so this reads
sysfs and forks nothing.

Free VRAM is read only to decide whether to start. It is never a reason to
stop: by the time free VRAM has dropped, the other job has already failed.
2026-08-02 17:03:56 +02:00
kami 94d553570d Merge pull request 'Run the big model on the workstation, with admission control and the 1.7B as the floor' (#94) from task/485-run-the-big-model-on-the-workstation-wit into master
Reviewed-on: #94
2026-08-02 17:03:25 +02:00
claude 2e97b905b4 docs: the workstation supervisor owns llama-server's lifecycle (V-485)
The remote model cannot be a llama-server that is simply left running: a
resident 7-14B holds 16GB against the CPT runs the card is for. So what
is always up on the workstation is a supervisor, and llama-server is
loaded while the card is free.

Still not a scheduler. It arbitrates nothing between callers, and Maven
never asks it to start anything.
2026-08-02 18:14:31 +04:00
claude fbcca449be llm: pin that a down workstation is invisible (V-485)
Seven cases. The load-bearing ones are the constraint from 483: an
unconfigured deploy never probes and always reaches the floor, a busy
card degrades silently with the remote untouched, and a remote that dies
between probes still completes the turn and corrects the cached answer on
its way out.

CompleteRemote is pinned not to fall back, because a named gap that
quietly became a 1.7B guess is the failure this whole split exists to
prevent. And 1000 Available calls are pinned to make zero probes.
2026-08-02 17:19:53 +04:00
claude 2076e4a788 llm: prefer the workstation model, floor on the resident one (V-485)
Pair holds both models and decides which answers. A prober asks the
remote whether it will take work and caches the answer, so a request
reads an atomic bool rather than paying for a health check. Routing sits
at p50 825ms on the hot path and must never wait on a machine that may be
asleep.

The two methods are the two halves of the degradation rule in
docs/offload.md. Complete falls back silently, for routing, replies and
nudge phrasing, where the big model is only better. CompleteRemote
returns ErrRemoteUnavailable instead, for a world question, where the
1.7B does not answer worse but invents.

A nil remote is the unconfigured deploy: nothing probes, everything goes
to the floor, and the box behaves exactly as it does today.
2026-08-02 17:19:53 +04:00
kami 30eb6add1b Merge pull request 'Docs: refresh the QA plan against the live task list' (#93) from task/483-docs-offload-design into master
Reviewed-on: #93
2026-08-02 15:08:42 +02:00
claude dc266056d1 docs: the shape and the rules for offloading model work (V-483)
483 is an umbrella and its children are the work, so what it owes them is
the shape they must all obey. docs/offload.md records it: the degradation
rule and where its line falls, admission control rather than a GPU
arbiter, the embedder staying on homesrv because it backs the classifier,
and the inventory of what runs a model on the box today.

CLAUDE.md gets a pointer, because an agent about to add a model caller or
touch a daemon seam needs to know this before it starts, not after.
2026-08-02 15:08:35 +02:00
kami 1c786b7156 Merge pull request 'Docs: refresh the QA plan against the live task list' (#92) from task/483-design-offload-ml-to-the-workstation-kee into master
Reviewed-on: #92
2026-08-02 15:08:16 +02:00
claude a3af10a830 gitignore the root .env, it holds a live token (V-484)
It was untracked but not ignored, so one git add -A would have committed
MAVEN_AMBIENT_TOKEN. Same class as deploy/telegram.env, which is already
ignored.
2026-08-02 15:08:07 +02:00
claude c0de473382 ipc, worker: dial and bind through netaddr (V-484)
Five hardcoded transports, three in internal/ipc and two in
internal/worker, all now go through the seam address. The unix perms
logic moved into netaddr, so the two copies of parentDir and the umask
dance are gone.

peerCaller already returned ok=false for a non-unix conn, so the
SO_PEERCRED path degrades correctly on tcp with no change.
2026-08-02 15:08:07 +02:00
claude 3e534340bf ipc: pin that a scheme-less address still dials unix (V-484)
Six cases. The load-bearing one is the first: every deploy in the tree
writes a bare path, and it must keep meaning a unix socket with no
handshake in front of the payload.

The rest cover the tcp seam: a good token round-trips, a wrong one comes
back ErrUnauthorized, a stranger that speaks HTTP at the port is dropped
while the listener stays up for the next peer, and a tokenless tcp bind
fails rather than serving his turns to anyone who connects.
2026-08-02 15:08:07 +02:00
claude 1a704d704d ipc: a seam address that can name a transport (V-484)
internal/netaddr parses a daemon seam address and dials or binds it. A
scheme-less address is unix and behaves exactly as it does today: same
0700 parent dir, same 0600 socket, same bytes on the wire. tcp://host:port
is the new option, and it is what lets a module live on another host.

Over tcp the filesystem permission that authenticated the unix socket is
gone, and what crosses this seam is audio of the owner speaking. So a tcp
listener requires a shared token, checked in constant time before the
first protocol frame is read, and a peer that fails is dropped without
taking the listener down with it.
2026-08-02 15:08:07 +02:00
kami e57adcb001 Merge pull request 'Docs: refresh the QA plan against the live task list' (#91) from task/459-docs-refresh-the-qa-plan-against-the-liv into master
Reviewed-on: #91
2026-08-02 15:07:21 +02:00
claude bec7362b7b config: clear three of 472's five QA blockers (V-459)
morning_routines, feeds, crawl.on_demand and netscan.enabled in
deploy/mavend.json; -ambient-token on mavweb, interpolated from a gitignored
/.env. All verified on the box: the dispatcher builds the morning plan,
/api/ambient answers 401/201, the crawler reads a named page, netscan finds 3
devices, and /events fills with scan:lan and ambient:notif.

Filed 482 (ambient reads a notification's wall clock as UTC). Corrected 479:
both capabilities work once configured, so it is not a routing defect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJYcaBiuny9UGSpFweQVQ1
2026-08-02 15:52:56 +04:00
claude a3ec746a01 docs: the six ready tasks all ran, and all six stop at the deploy (V-459) 2026-08-02 15:44:17 +04:00
claude af0eec250e docs: the operations sitting and the degraded-mode suite both ran (V-459) 2026-08-02 15:15:08 +04:00
claude 20aa2d59c9 docs: session 3 results and the query-source findings (V-459) 2026-08-02 14:40:22 +04:00
claude 4bad90dedb docs: point the QA findings at their new task ids (V-459) 2026-08-02 14:25:39 +04:00
claude 2b8d0f74fa docs: session 1 and 2 results, and the classifier baseline was wrong (V-459)
Ran sessions 1 and 2 on the live box.

Session 1 steps 1 and 3-6 pass. Steps 2 and 7-9 need a person at the box.
POST /api/chat is drivable with form encoding and a cookie jar, so the text
half needs no browser.

Session 2 confirms the deploy matches the bench at 72.7% full accuracy, and
contradicts two recorded numbers. The classifier scores 68.8% at p50 16.6us,
not 36.8% at 31ms. Router latency measured under contention again.

Also: 319 item 2 point 2 closes on the recall margin sweep, CheckFeminine has
a false positive on second-person masculine verbs, and the wake path cannot be
checked because mavwaked and mavenclient are deployed nowhere.
2026-08-02 14:24:01 +04:00
claude af9d2133dc docs: session 3 holds five sittings now, not three (V-459)
The refresh added the query-sources and operations sittings and left the
heading counting three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJYcaBiuny9UGSpFweQVQ1
2026-08-02 13:57:01 +04:00
claude a1fdfccd61 docs: refresh the QA plan against the live task list (V-459)
The plan named 40 task numbers on 2026-08-01. Ten open QA tasks were missing
and two of the named ones had closed, so the 44-of-50 header was wrong twice
over.

- header is 42 of 50, and every open task now appears
- placed the ten unlisted QA tasks: 14, 248, 249, 250, 258, 283, 284, 285,
  286, 323
- new Operations sitting for 249 and 250, and a Query sources sitting for
  258 and 286
- 14 and 284 join housekeeping: both are gated on something unbuilt
- dropped the 317 and 354 rows, closed 01-08-2026, with one line saying what
  landed
- 319's gate recalibration is done; what is left is re-deriving QueryMinMargin
- 323 is down to the 60s startup timeout arm after PR #90
- new "Not this repo" section for 358 (Hexis) and 362 (training workspace)
- router latency is ~27x, not 90x; the 2.7s p50 was contention, not the model

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NJYcaBiuny9UGSpFweQVQ1
2026-08-02 13:56:02 +04:00
kami 5c05163266 Merge pull request 'QA: phraser coverage is 65.3% but the llama-server subprocess lifecycle is 0% — the suspicion in this task was correct' (#90) from task/323-qa-phraser-coverage-is-65-3-but-the-llam into master 2026-08-02 11:39:20 +02:00
kami 92d2629001 Merge pull request 'Voice cannot accept a routine (V-367); the last three prompts are Russian (V-404)' (#89) from fix/367-voice-parks-routine-accept into master 2026-08-02 11:39:16 +02:00
kami bdcfccce77 Merge pull request 'Session workflow: pickup and wrap around the task flow' (#85) from task/445-session-workflow into master 2026-08-02 11:39:11 +02:00
kami f4deccacc9 Merge pull request 'dialogue.Slots and router.Slots are hand-kept copies that already drifted' (#88) from task/365-dialogue-slots-and-router-slots-are-hand into master 2026-08-02 11:39:07 +02:00
kami 8aaac01de6 Merge pull request 'Doc reorg: tier the tree, retire the three planning files' (#86) from task/446-doc-reorg-tier-the-tree-retire-the-three into master 2026-08-02 11:37:43 +02:00
claude feb6f2c03d phraser: test the spawn path, the one thing coverage never touched (V-323)
Every phraser test built the phraser with NewLLMPhraserAt, which starts no
process, so NewLLMPhraser, spawnLlamaServer, startLlamaProc, llamaProc.Close
and extractPort sat at 0% while the package headline read 65.3%.

These drive the real spawn code against a fake llama-server script: the port
scrape, the three reachable startup-race arms (start failure, stderr EOF,
context cancel), and Close actually reaping the child. The orphan test
re-execs the test binary as the daemon, SIGKILLs it, and asserts Pdeathsig
killed the grandchild. The last test rebuilds the production command line and
checks kill-maven.sh's pattern still matches it — that pattern has gone stale
twice and leaked orphans both times.

Package coverage 65.3% -> 76.9%. The 60s timeout arm stays untested; it needs
an injectable clock in production code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012YQGVXu5J1iCMCff5J4S1R
2026-08-02 10:15:35 +04:00
claude 99bb3526db Stop asking for Russian in English on the last three prompts (V-404)
#400 rewrote the chat and query prompts in Russian and left three pieces
of English prose behind.

PhraseReminder's user prompt was fully English. It is Russian now, and it
no longer restates the JSON contract or the persona rules: the call goes
through chat(), so nudgeSystem already states both, and a second copy of a
contract is one more thing that can drift out of step with the first.

querySystemPrompt and router.KnowledgePrompt both closed with the English
"Respond ONLY with valid JSON:". That sentence is prose instruction, not
wire format — the JSON skeleton after it is the wire format, and it is
unchanged. Kept rather than deleted: the GBNF grammar makes it close to
redundant, but the grammar is switchable off (phraser NoGrammar), and the
sentence is the floor when it is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CotYKycuio1GwLbYh9jfc
2026-08-02 10:01:58 +04:00
claude bb8cb8d014 Voice parks a routine proposal, it never accepts it (V-367)
Accepting a proposed routine gives the tick loop a standing new reason to
speak. DESIGN.md § "surface caps authority" puts that at layer 3, and says
voice is structurally incapable of layer 3 because a room mic is reachable
by anyone in the room. The /routines button was gated at step-up; the voice
path accepted outright. The two surfaces disagreed, so one of them was wrong.

A spoken "да" now leaves the row 'proposed' and sends him to /routines,
where the gated button is. A spoken "нет" still dismisses: declining does
not move the boundary outward, so voice keeps it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CotYKycuio1GwLbYh9jfc
2026-08-02 09:58:07 +04:00
claude 5e66aa8f22 fix: the slots converter was still dropping the fact Value (V-365)
dialogue.Slots gained Value in 925ce22, but toDialogueSlots never copied
it, so a clarifying answer carrying a fact payload still landed nowhere:
clarify.go:202 sends the answer through the converter, and the SlotValue
arm reads answer.Value.

Both converters now carry every field. TestSlotsParity compares the two
field sets by name and type; TestSlotsRoundTrip populates every router
field and checks the round trip, and fails the fixture itself when a new
field is left zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QChoBS5qJSrCV98oNUnHNU
2026-08-02 09:35:47 +04:00
kami c0d61a71a4 session: pickup and wrap around the task flow, one disposable handoff (V-445)
task start and task pr already own the branch, the identity and the PR.
What was missing sat on either side of them.

pickup runs task start, reads TASK.md and any handoff, then restates the
assumption set and stops. That pause is the point: every wasted session
here began with an agent that inferred the goal instead of stating it
back. wrap runs the tests, updates the durable docs, commits in slices,
calls task pr, and records in Vikunja what task pr cannot know.

HANDOFF.md is gitignored and injected by a SessionStart hook. It holds
what the next agent needs to resume and nothing else. TASK.md is the
brief for the branch and does not change. Anything that would still
matter next week goes to Vikunja, CLAUDE.md or docs/.

CLAUDE.md documented none of this, which is why an agent would rebuild
it. It does now, including the two hooks in ~/.claude/hooks.

.claude/ was ignored wholesale. The workflow is now tracked, because how
a session behaves should be reviewed like code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 03:22:46 +04:00
kami 0b89294af7 hooks: refuse master, cap a code commit at 300 lines, require the task ref (V-445)
Two git hooks, tracked in .githooks and wired with core.hooksPath so a
fresh clone gets them with one config line.

pre-commit refuses master and refuses more than 300 changed lines in
non-markdown files. Markdown is exempt because docs land as one batch.
This is a commit-time guard, which diff-budget.sh is not: that hook
blocks the agent's edits and says nothing when either of us commits.

commit-msg requires (V-<id>), not (#<id>). Gitea autolinks # to its own
issues, and Vikunja is the tracker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 03:22:46 +04:00
35 changed files with 3170 additions and 166 deletions
+160
View File
@@ -0,0 +1,160 @@
# Maven project dictionary for the direct-prose skill.
#
# These terms override every word preference in the skill's word-choice tables.
# Each entry exists because the name drifted in real docs or real answers, not
# because the word looked improvable.
#
# Format and the rule for adding a term: ~/.claude/skills/direct-prose/references/modes.md
terms:
resident_model:
name: resident model
meaning: the one always-warm Qwen3-1.7B llama-server that both routes and phrases
avoid:
- the model
- the LLM
- the 1.7B
- the phraser model
examples:
good: The resident model emits GBNF-constrained JSON.
bad: The 1.7B emits GBNF-constrained JSON.
router:
name: router
meaning: the stage that turns an utterance into a Decision with one of 7 intents
avoid:
- orchestrator
- intent classifier
- dispatcher
classifier:
name: classifier
meaning: the embedder nearest-neighbour path that runs when the router is off or errors
avoid:
- the fallback
- the floor
- the old router
examples:
good: A router error falls through to the classifier.
bad: A router error falls through to the floor.
cascade:
name: cascade
meaning: the ordered path stage 0, then router, then classifier
avoid:
- the pipeline
- the chain
- the fallback chain
stage_0:
name: stage 0
meaning: the deterministic rules that answer before the resident model is called
avoid:
- the fast path
- bypass
- deterministic assist
- preemption
examples:
good: Stage 0 routes agenda questions to IntentQuery.
bad: The bypass routes agenda questions to IntentQuery.
query_source:
name: query source
meaning: one entry in querySources, which either claims a turn or passes
avoid:
- arm
- handler
- branch
- answerer
examples:
good: Kiwix is the last query source before the model answers from memory.
bad: Kiwix is the last arm before the model answers from memory.
personal_boundary:
name: personal boundary
meaning: the query source that stops a question about him from reaching the world
avoid:
- the boundary
- the privacy gate
- the personal filter
clarify:
name: clarify
meaning: the turn outcome where Maven asks instead of acting
avoid:
- refusal
- rejection
- punt
examples:
good: The gate produced two false clarifies.
bad: The gate produced two false refusals.
fact:
name: fact
meaning: a keyed, supersedable row in the fact store
avoid:
- memory entry
- datum
- record
note:
name: note
meaning: free text he captured, indexed for recall
avoid:
- memo
- entry
memory:
name: memory
meaning: the embedded index over notes and facts that backs recall
avoid:
- RAG store
- vector db
- long-term memory
nudge:
name: nudge
meaning: one proactive message the digestion worker proposes and the dispatcher sends
avoid:
- suggestion
- proposal
- proactive prompt
- reminder
examples:
good: A fact can close the nudge that asked for it.
bad: A fact can close the suggestion that asked for it.
digestion_worker:
name: digestion worker
meaning: the background engine that consolidates memory and proposes nudges
avoid:
- digestion tick
- background engine
- reflection loop
reach:
name: reach
meaning: an outbound channel Maven speaks through, such as telegram, ntfy or voice
avoid:
- sink
- delivery channel
- notification backend
ecosystem:
name: ecosystem
meaning: Nexus, Praxis and Hexis together
avoid:
- the services
- the integrations
- upstream
act:
name: act
meaning: the intent that runs a capability through Hexis
avoid:
- action
- command
- execution
examples:
good: An act with no allowlisted fn is gated to a clarify.
bad: An action with no allowlisted fn is gated to a clarify.
+20
View File
@@ -0,0 +1,20 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "f=.claude/prose-dictionary.yaml; [ -f \"$f\" ] && jq -Rs '{hookSpecificOutput:{hookEventName:\"SessionStart\",additionalContext:(\"Project prose dictionary. These terms override every word preference in the direct-prose output style. Use the name, never the avoid list.\\n\\n\"+.)}}' \"$f\" 2>/dev/null || true",
"statusMessage": "Loading prose dictionary"
},
{
"type": "command",
"command": "f=HANDOFF.md; [ -f \"$f\" ] && jq -Rs '{hookSpecificOutput:{hookEventName:\"SessionStart\",additionalContext:(\"An unconsumed HANDOFF.md is present. Run the pickup skill before anything else: read it, read the Vikunja task it names, restate the assumption set in at most five bullets, and wait for the user to confirm before writing code. It is a claim from the previous session, not truth. Delete it once consumed.\\n\\n\"+.)}}' \"$f\" 2>/dev/null || true",
"statusMessage": "Loading handoff"
}
]
}
]
}
}
+78
View File
@@ -0,0 +1,78 @@
---
name: pickup
description: Start a work session on a Maven task. Runs task start, reads the brief and the disposable handoff, restates the assumption set, and waits for correction before touching code. Use at the start of any session that continues earlier work, when the user says "pickup", "continue", "resume", or names a Vikunja task id.
---
# Pickup
The point of this skill is the pause in step 5. Every wasted session in this repo
started with an agent that inferred the goal instead of stating it back.
## 0. Get on the branch
```sh
task start <vikunja-id>
```
`~/.local/bin/task` owns the branch, the identity and the PR. It cuts
`task/<id>-<slug>` off `origin/master` and sets the commit author to the `claude`
gitea user. It writes `TASK.md` from the Vikunja task, and pulls any waiting
review comments into `.task/review-comments.md`. Do not hand-roll any of that.
`TASK.md` is the brief and it is immutable. If it says a PR already exists, this
is a review-fix session and not new work. Read the comments first.
## 1. Read the handoff
`HANDOFF.md` at the repo root, if it exists. It is gitignored, it belongs to one
session, and it holds only what is needed to resume. Treat it as a claim from the
previous agent, not as truth. It can be stale or wrong.
If there is no handoff, that is normal. It means the last session closed clean.
## 2. Read the durable state
In this order, and stop as soon as you have enough:
- The Vikunja task, by id. Project Maven is ID 2, MCP at `http://localhost:9100/mcp`.
The task description and its comments hold the goal, the constraints, and the
assumption ledger. This outranks the handoff on every conflict.
- `CLAUDE.md`, the section that covers the area you are about to touch.
- The one file under `docs/` that owns the area. Check its `Last verified` line.
If the sha is behind the code you are reading, say so in step 4 and trust the code.
Do not read the dated files under `docs/evals/`. They are measurements from one day,
never updated. Read one only when you need the number it recorded.
If no task id is known, ask for one before doing anything else. Work without a task
is work nobody can resume.
## 3. Look at the ground
`git status`, `git log --oneline -5`, and the diff on the current branch. What the
repo says beats what any document says.
## 4. Restate, then stop
Write at most five bullets and stop. Do not write code, do not open files to "check
one thing first", do not start with a small safe change.
```
Task: V-359, one line.
Done: what is already on the branch.
Next: the one thing this session does.
Constraints: what would make this wrong.
Assuming: the beliefs that, if false, waste the session.
```
Then ask: is this right? Wait for the answer.
A corrected assumption goes into the Vikunja task as a comment, not into the handoff.
The handoff dies tonight. The task does not.
## 5. Then begin
- Delete `HANDOFF.md`. It has been consumed and must not outlive this step.
- On master, cut the branch: `scripts/task-branch.sh <id> <slug>`.
- One task per session. When context passes roughly half, run `/wrap` rather than
pushing on. A compacted session is a session that forgot why it made a choice.
+94
View File
@@ -0,0 +1,94 @@
---
name: wrap
description: Close a Maven work session cleanly. Runs the tests, updates the durable docs, commits in reviewable slices with the Vikunja ref, pushes so the PR opens, records state in Vikunja, and leaves a disposable handoff only if work remains. Use when the user says "wrap", "wrap up", "done for now", or when context passes roughly half.
---
# Wrap
Run every step. A partial wrap is worse than none, because the next session trusts
the parts that did run.
## 1. Prove it works
`make test`. If something fails, fix it or say plainly in the handoff and in Vikunja
that it fails, with the output. Never wrap on an untested claim.
## 2. Update the durable docs
Ask what a future agent would have to learn the hard way, and write that down.
- `CLAUDE.md` when a fact an agent needs before touching code has changed: routing
behaviour, a measured number, a flag default, a constraint. A commit that changed
routing or phrasing without touching the matching CLAUDE.md section is a bug.
Correct stale text in place. Do not append a new paragraph next to the wrong one.
- `AGENTS.md` when the recipe to build, run or preview changed.
- The one file under `docs/` that owns the area, plus its `Last verified: <date> @ <sha>`
line. Only a doc directly under `docs/` carries that line.
- A new dated file under `docs/evals/` when you measured something. Never edit an
existing dated file. A newer measurement is a new file, and the living doc points
at it.
Nothing that must survive tonight goes anywhere else. Not into the handoff, not into
a commit message, not into a comment in the code.
## 3. Commit in slices
Under 300 changed lines per commit in non-markdown files, enforced by `.githooks/pre-commit`.
Markdown is exempt and may land as one batch.
Each commit is one idea, subject in the repo's voice, lowercase area prefix, and it
ends with the Vikunja ref:
```
router: narrow the single-token rule (V-359)
```
If a change genuinely cannot split under 300 lines, say why in the commit body before
reaching for `--no-verify`.
## 4. Land it
```sh
task pr
```
It refuses a dirty tree, pushes, opens or refreshes the PR against the repo default
branch, labels the Vikunja task in-review, comments the PR url on it, and pushes an
ntfy. Do not push by hand and do not call `tea` yourself.
## 5. Record what `task pr` cannot know
Comment on the Vikunja task: what you measured, what is still open. List every
assumption that turned out to be wrong. If the session found new work, create a task
for it now rather than describing it in prose.
This step is what makes the handoff disposable.
## 6. Leave the handoff, or leave none
If the task is finished, delete `HANDOFF.md` and stop. An empty root is the correct
end state.
If work remains, write `HANDOFF.md` with nothing but what the next agent needs to
resume, and no history:
```markdown
# Handoff — <date>
Task: V-359 <one line>
Branch: task/359-<slug>, cut from master
## Where I stopped
<two sentences, mid-thought detail that is nowhere else>
## Next action
<the single concrete next step>
## Do not
<the trap I nearly fell into, or the approach already ruled out>
```
Nothing else goes in it. No summary of what landed, that is in git and Vikunja. No
design rationale, that is in `docs/`. No fact an agent needs on any task, that is in
`CLAUDE.md`. If a line in the handoff would still matter next week, it is in the wrong
file.
+30
View File
@@ -0,0 +1,30 @@
#!/bin/sh
# Every commit names the Vikunja task it belongs to.
#
# router: narrow the single-token rule (V-359)
#
# V- and not #, because Gitea autolinks #359 to a Gitea issue, which is a
# different tracker and a wrong link.
#
# Exempt: merges, reverts, fixup/squash, and the initial commit.
msg_file=$1
subject=$(sed -n '1p' "$msg_file")
case "$subject" in
Merge\ *|Revert\ *|fixup!\ *|squash!\ *|amend!\ *) exit 0 ;;
esac
if [ -f "$(git rev-parse --git-dir)/MERGE_HEAD" ]; then
exit 0
fi
if printf '%s' "$subject" | grep -qE '\(V-[0-9]+\)$'; then
exit 0
fi
echo "commit-msg: subject must end with a Vikunja task ref." >&2
echo " got: $subject" >&2
echo " want: router: narrow the single-token rule (V-359)" >&2
echo " No task yet? Create one. Work without a task is work nobody can resume." >&2
exit 1
+30
View File
@@ -0,0 +1,30 @@
#!/bin/sh
# Two guards, both bypassable with --no-verify when you mean it.
# 1. master is not a working branch.
# 2. a code commit stays under 300 changed lines.
# Markdown is exempt from the size cap on purpose: docs land as one batch.
branch=$(git symbolic-ref --short HEAD 2>/dev/null)
case "$branch" in
master|main)
echo "pre-commit: refusing to commit on $branch." >&2
echo " task start <vikunja-id> # branch off origin/master, write TASK.md" >&2
exit 1
;;
esac
# Added + deleted lines across staged files that are not markdown.
# numstat prints "-\t-\t<path>" for binaries; those count 0 and that is fine,
# a binary blob is not the kind of diff this cap exists to stop.
loc=$(git diff --cached --numstat -- . ':(exclude)*.md' |
awk '$1 ~ /^[0-9]+$/ { a += $1 } $2 ~ /^[0-9]+$/ { d += $2 } END { print a + d + 0 }')
if [ "$loc" -gt 300 ]; then
echo "pre-commit: $loc changed lines in non-markdown files, cap is 300." >&2
echo " Split it. Each commit should be one reviewable idea." >&2
echo " git reset <path> to unstage, or --no-verify if this genuinely cannot split." >&2
exit 1
fi
exit 0
+18 -2
View File
@@ -9,6 +9,7 @@
/mavwaked
/mavmaild
/mavupdate
/mavgpud
# Certs (private keys, don't commit)
certs/
@@ -40,6 +41,9 @@ deploy/telegram.env
deploy/zenmoney.token
# IMAP password, read by mavmaild (never in argv, never committed)
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
/tmp/
@@ -50,7 +54,19 @@ opencode.json
# Test coverage output
coverage.out
# Agent worktrees and local agent state
.claude/
# Agent worktrees and local agent state. The workflow itself is tracked: the
# hooks, the skills and the prose dictionary are how a session behaves, so they
# get reviewed like code. Everything else under .claude/ is scratch.
/.claude/*
!/.claude/settings.json
!/.claude/prose-dictionary.yaml
!/.claude/skills/
# The disposable handoff. One session, then deleted. Never committed:
# anything worth keeping belongs in Vikunja, CLAUDE.md or docs/.
/HANDOFF.md
/models/stt
/models/tts
# root .env — MAVEN_AMBIENT_TOKEN and friends, same class as deploy/telegram.env
.env
+72 -5
View File
@@ -28,6 +28,14 @@ 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
`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
CGO daemons (`mavend`, `mavsttd`, `mavttsd`, `mavenclient`) need the vendored toolchain
@@ -127,11 +135,13 @@ 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
fallback. Any LLM error falls through to the classifier so a turn never breaks on the model.
Measured on the 77-case RU fixture (`docs/evals/2026-07-31-model-bakeoff.md`): the classifier scores
36.8% full accuracy at p50 31ms; Qwen3-1.7B scores 67.5% intent-only / 72.7% through the
cascade at p50 ≈825ms. Accuracy roughly doubled, latency is ~27× worse, and that trade was
accepted deliberately. **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
Measured on the 77-case RU fixture. **Re-measured 2026-08-02: the classifier scores 68.8%
full accuracy at p50 16.6µs**, not the 36.8% at p50 31ms that stood here from
`docs/evals/2026-07-31-model-bakeoff.md`. That older figure predates the stage 0 rules and the
seed additions, both of which now score inside the classifier baseline. Qwen3-1.7B scores
77.9% intent-only / 72.7% through the cascade. So the router buys about 4 points of accuracy,
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
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
@@ -213,3 +223,60 @@ data pans on a phone. Local preview + headless screenshot recipe is in `AGENTS.m
This repo is project **Maven** (ID 2) in Vikunja. MCP: `http://localhost:9100/mcp` (or
`http://192.168.1.104:9100/mcp` from workpc). Feature/bug/deploy tasks go there.
Vikunja is the durable task store. A task holds the goal, the constraints and the
assumption ledger. Work without a task id is work nobody can resume, so a session that
has no id asks for one before it starts.
## Session workflow
`~/.local/bin/task` owns the branch, the commit identity and the PR. One task, one
session, one PR.
```sh
task start <vikunja-id> # branch off origin/master, write TASK.md, fetch review comments
task pr # push, open or refresh the PR, label Vikunja, notify
task comments # re-pull this branch's review comments into .task/
```
Around that, `/pickup` opens a session and `/wrap` closes it. Wrap at roughly half
context rather than letting the session compact.
Five stores, and each one owns something the others must not hold:
| Store | Holds | Lifetime |
|---|---|---|
| Vikunja task | goal, constraints, assumption ledger, status | durable |
| `CLAUDE.md`, `AGENTS.md` | what an agent must know before touching code | durable |
| `docs/` | design, measurements, decisions | durable |
| `TASK.md` | the brief for this branch, written by `task start`, immutable | one branch |
| `HANDOFF.md` | only what the next agent needs to resume | one session |
`TASK.md` and `.task/` are excluded through `.git/info/exclude`. `HANDOFF.md` is
gitignored and injected at session start. If a line in the handoff would still matter
next week, it is in the wrong file.
Docs are tiered by path, so staleness is visible from the filename. Files directly under
`docs/` are living and carry a `Last verified: <date> @ <sha>` line. Files under
`docs/evals/` are dated measurements and are never edited after the day, so a newer
number is a new file. Files under `docs/archive/` are dead and read by nobody by default.
## Git guards
Two hooks in `.githooks/`, tracked, wired with `core.hooksPath`. Fresh clone:
```sh
git config core.hooksPath .githooks
```
- `pre-commit` refuses master, and refuses more than 300 changed lines in non-markdown
files. Markdown is exempt and may land as one batch.
- `commit-msg` requires the subject to end with `(V-<id>)`. `V-` and not `#`, because
Gitea autolinks `#123` to a Gitea issue, which is the wrong tracker.
Two more guards live outside the repo, in `~/.claude/hooks/`. `diff-budget.sh` blocks
further edits past 600 changed lines on a `task/` branch. `prose_lint_hook.py` checks
prose on every write. Both measure against `origin/master`, so a local master that is
ahead of the remote makes the diff budget read high.
`--no-verify` exists. Using it means saying why in the commit body.
+8 -2
View File
@@ -16,11 +16,11 @@ PIPER_BIN := $(shell pwd)/deps/piper/piper
PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx
PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data
.PHONY: simulate stt-fixtures test-stt-golden all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go eval-router eval-recall eval-phrasing eval-models
.PHONY: simulate stt-fixtures test-stt-golden all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go eval-router eval-recall eval-phrasing eval-models build-gpud
all: build
build: build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav build-mail build-update
build: build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav build-mail build-update build-gpud
build-stt:
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
@@ -59,6 +59,12 @@ build-mail:
build-update:
$(GO) build $(GOFLAGS) -o mavupdate ./cmd/mavupdate/
# mavgpud runs on the workstation, not here. It is built with the rest so a
# broken supervisor is caught by `make build` on homesrv rather than by the
# workstation refusing to serve. Copy the binary over, do not `make deploy` it.
build-gpud:
$(GO) build $(GOFLAGS) -o mavgpud ./cmd/mavgpud/
run-web: build-web
./mavweb -addr :9200 -voice 127.0.0.1:9100
+12 -8
View File
@@ -107,14 +107,18 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve
return pr != nil && !h.now().After(pr.expiry)
},
yes: func() string {
// Only record the acceptance. The tick loop reads accepted
// routines and nudges on their own interval. Building a
// reminder here made a routine fire exactly once (Vikunja #366).
if err := h.dataStore.AcceptProposedRoutine(ctx, pr.routineID, h.now()); err != nil {
log.Printf("voice: accept proposed routine: %v", err)
return "не получилось запомнить рутину."
}
return "буду напоминать."
// Voice does NOT accept (Vikunja #367). Accepting hands the
// tick loop a standing new reason to speak, which is the same
// tier as enabling a tool — and DESIGN.md § "surface caps
// authority" says a room mic, reachable by anyone present, is
// structurally incapable of layer 3. So a spoken "да" leaves
// the row 'proposed' and points at the authed page, where the
// accept button is gated at step-up. The convenience of
// answering out loud stays; the authority does not move.
//
// Acceptance itself is recorded by /routines, and the tick
// loop nudges on the interval from there (Vikunja #366).
return "поняла — подтверди на странице рутин, и начну напоминать."
},
no: func() string {
if err := h.dataStore.DismissProposedRoutine(ctx, pr.routineID); err != nil {
+12 -5
View File
@@ -12,13 +12,21 @@ import (
// which waits on voice-print attribution (see PROGRESS multi-user deferral).
const voiceDialogueID = "voice"
// toDialogueSlots projects the router's slots onto the dialogue layer's subset
// (everything except the fact Value, which the dialogue layer doesn't carry).
// toDialogueSlots and applyDialogueSlots are the only bridge between
// router.Slots and dialogue.Slots. dialogue must not import router (import
// cycle), so the two structs are hand-kept copies and every field has to be
// carried by hand here. Adding a field to either struct without adding it to
// BOTH functions loses a slot silently — nothing fails to build. The tests in
// slotsparity_test.go fail when the field sets or the converters stop matching;
// when they do, fix these two functions, not the tests.
// toDialogueSlots projects the router's slots onto the dialogue layer's copy.
func toDialogueSlots(s router.Slots) dialogue.Slots {
return dialogue.Slots{
Time: s.Time,
HasTime: s.HasTime,
Key: s.Key,
Value: s.Value,
HasKey: s.HasKey,
Text: s.Text,
Fn: s.Fn,
@@ -27,11 +35,10 @@ func toDialogueSlots(s router.Slots) dialogue.Slots {
}
}
// applyDialogueSlots writes inherited dialogue slots back onto router slots,
// preserving router-only fields (Value) the dialogue layer never touched.
// applyDialogueSlots writes dialogue slots back onto router slots.
func applyDialogueSlots(base router.Slots, d dialogue.Slots) router.Slots {
base.Time, base.HasTime = d.Time, d.HasTime
base.Key, base.HasKey = d.Key, d.HasKey
base.Key, base.Value, base.HasKey = d.Key, d.Value, d.HasKey
base.Text = d.Text
base.Fn, base.Args, base.HasFn = d.Fn, d.Args, d.HasFn
return base
+79
View File
@@ -9,6 +9,7 @@ import (
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/pattern"
"github.com/kami/maven/internal/store"
@@ -283,3 +284,81 @@ func TestTickProposalCooldownSpacesAnnouncements(t *testing.T) {
}
}
}
// TestVoiceYesDoesNotAcceptRoutine — Vikunja #367. Accepting a routine hands
// the tick loop a standing new reason to speak, which DESIGN.md puts at layer
// 3, and voice is structurally incapable of layer 3. A spoken "да" must park
// the decision for the authed page, not flip the row itself.
func TestVoiceYesDoesNotAcceptRoutine(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
now := refNow()
seedRefillEvents(t, st, ctx, now, pattern.MinEvents-1)
h := &reactiveHandler{api: ipc.NewStoreAPI(st), dataStore: st, now: func() time.Time { return now }}
// The MinEvents'th event is the one that makes the pattern detectable, and
// it goes through the voice path so the proposal is parked for a y/n.
last := now.Add(time.Duration(pattern.MinEvents-1) * 7 * 24 * time.Hour)
factID, err := st.WriteFact(ctx, last, store.KindSelf, "cat_water", "refill", "voice", 1.0, sql.NullInt64{})
if err != nil {
t.Fatalf("write fact: %v", err)
}
if phrase := h.detectPattern(ctx, factID, "cat_water", "refill", last); phrase == "" {
t.Fatal("expected a parked routine proposal")
}
reply, handled := h.resolveConfirm(ctx, "да")
if !handled {
t.Fatal("the spoken yes should be consumed by the routine confirm")
}
if !strings.Contains(reply, "рутин") {
t.Fatalf("reply should send him to the routines page, got %q", reply)
}
rows, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineAccepted)
if err != nil {
t.Fatalf("list accepted: %v", err)
}
if len(rows) != 0 {
t.Fatalf("voice accepted a routine: %+v", rows)
}
proposed, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed)
if err != nil {
t.Fatalf("list proposed: %v", err)
}
if len(proposed) != 1 {
t.Fatalf("proposed routines = %d, want 1 (still waiting for the page)", len(proposed))
}
}
// TestVoiceNoStillDismissesRoutine — declining does not move the boundary
// outward, so voice keeps it. Only acceptance is gated.
func TestVoiceNoStillDismissesRoutine(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
now := refNow()
seedRefillEvents(t, st, ctx, now, pattern.MinEvents-1)
h := &reactiveHandler{api: ipc.NewStoreAPI(st), dataStore: st, now: func() time.Time { return now }}
last := now.Add(time.Duration(pattern.MinEvents-1) * 7 * 24 * time.Hour)
factID, err := st.WriteFact(ctx, last, store.KindSelf, "cat_water", "refill", "voice", 1.0, sql.NullInt64{})
if err != nil {
t.Fatalf("write fact: %v", err)
}
if phrase := h.detectPattern(ctx, factID, "cat_water", "refill", last); phrase == "" {
t.Fatal("expected a parked routine proposal")
}
if _, handled := h.resolveConfirm(ctx, "нет"); !handled {
t.Fatal("the spoken no should be consumed by the routine confirm")
}
rows, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineDismissed)
if err != nil {
t.Fatalf("list dismissed: %v", err)
}
if len(rows) != 1 {
t.Fatalf("dismissed routines = %d, want 1", len(rows))
}
}
+71
View File
@@ -0,0 +1,71 @@
package main
import (
"reflect"
"testing"
"time"
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/router"
)
// TestSlotsParity — dialogue.Slots is a hand-kept copy of router.Slots
// (dialogue must not import router: import cycle). Drift is silent, so this
// test compares the two field sets by name and type. If it fails, add the new
// field to both structs AND to toDialogueSlots/applyDialogueSlots in
// followup.go — do not relax the test.
func TestSlotsParity(t *testing.T) {
fields := func(v any) map[string]string {
rt := reflect.TypeOf(v)
out := make(map[string]string, rt.NumField())
for i := 0; i < rt.NumField(); i++ {
f := rt.Field(i)
out[f.Name] = f.Type.String()
}
return out
}
rf, df := fields(router.Slots{}), fields(dialogue.Slots{})
for name, typ := range rf {
dt, ok := df[name]
if !ok {
t.Errorf("router.Slots.%s (%s) missing from dialogue.Slots", name, typ)
continue
}
if dt != typ {
t.Errorf("field %s: router has %s, dialogue has %s", name, typ, dt)
}
}
for name, typ := range df {
if _, ok := rf[name]; !ok {
t.Errorf("dialogue.Slots.%s (%s) missing from router.Slots", name, typ)
}
}
}
// TestSlotsRoundTrip — the converters carry every field. A field the parity
// test accepts can still be dropped in transit, so round-trip a fully
// populated value and compare.
func TestSlotsRoundTrip(t *testing.T) {
full := router.Slots{
Time: time.Date(2026, 8, 2, 11, 0, 0, 0, time.UTC),
HasTime: true,
Fn: "restart",
Args: []string{"nginx"},
HasFn: true,
Key: "water",
Value: `"drank"`,
HasKey: true,
Text: "выпил воды",
}
// Every field must be non-zero, or the round-trip proves nothing.
rv := reflect.ValueOf(full)
for i := 0; i < rv.NumField(); i++ {
if rv.Field(i).IsZero() {
t.Fatalf("field %s is zero: extend this fixture so the round-trip covers it",
rv.Type().Field(i).Name)
}
}
if got := applyDialogueSlots(router.Slots{}, toDialogueSlots(full)); !reflect.DeepEqual(got, full) {
t.Errorf("round-trip lost a slot:\n got %+v\nwant %+v", got, full)
}
}
+117
View File
@@ -0,0 +1,117 @@
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))
}
+103
View File
@@ -0,0 +1,103 @@
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
}
+247
View File
@@ -0,0 +1,247 @@
// 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
}
+132
View File
@@ -0,0 +1,132 @@
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")
}
}
+4 -7
View File
@@ -1210,13 +1210,10 @@ func routineRows(rs []ipc.ProposedRoutine) []routineRow {
return out
}
// acceptRoutine creates the recurring reminder for a proposal, then marks the
// proposal accepted and links the reminder to it. Weekly patterns get a cron
// expression; any other interval fires once.
//
// TODO(vikunja#46): this mirrors the voice accept path in cmd/mavend/voice.go.
// When the tick loop learns to read accepted proposals directly, both callers
// should hand off to one place in core instead of each building a reminder.
// acceptRoutine marks a proposal accepted. This page is the ONLY surface that
// may do it (Vikunja #367): accepting gives the tick loop a standing new
// reason to speak, which DESIGN.md puts at layer 3, and the button here is
// behind step-up. Voice can park the question and dismiss, never accept.
func acceptRoutine(ctx context.Context, core ipc.CoreAPI, id int64) error {
proposed, err := core.ListProposedRoutines(ctx)
if err != nil {
+51 -1
View File
@@ -76,6 +76,56 @@
"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": {
"enabled": true,
"window": "30m",
@@ -119,7 +169,7 @@
"timeout": "400ms",
"rate": 100,
"max_hosts": 256,
"enabled": false
"enabled": true
},
"nexus": { "url": "http://nexus:9740" },
+32
View File
@@ -0,0 +1,32 @@
{
"listen": ":8080",
"llama_addr": "127.0.0.1:10000",
"llama_bin": "llama-server",
"llama_args": [
"-m", "/mnt/D/AI/gemma4/gemma-4-12B-it-qat-UD-Q4_K_XL.gguf",
"-md", "/mnt/D/AI/gemma4/mtp-gemma-4-12B-it-BF16.gguf",
"-ngl", "99",
"-fa", "on",
"-np", "1",
"--host", "127.0.0.1",
"--port", "10000",
"--ctx-size", "32768",
"--threads", "6",
"--batch-size", "2048",
"--ubatch-size", "512",
"--jinja",
"--chat-template-kwargs", "{\"enable_thinking\":false}",
"--spec-type", "draft-mtp",
"--spec-draft-n-max", "2"
],
"kfd_root": "/sys/class/kfd/kfd/proc",
"drm_device": "/sys/class/drm/card1/device",
"poll": "1s",
"idle_timeout": "15m",
"stop_grace": "20s",
"min_free_vram_bytes": 10737418240,
"evict_after_polls": 2,
"start_after_polls": 5
}
+24
View File
@@ -0,0 +1,24 @@
[Unit]
# Runs on the workstation (bugmachine), not on homesrv. Install as a systemd
# user unit and turn on lingering, so the card is supervised after a reboot
# with nobody logged in:
#
# scp mavgpud workpc:~/.local/bin/mavgpud
# scp deploy/mavgpud.json workpc:~/.config/mavgpud.json
# scp deploy/mavgpud.service workpc:~/.config/systemd/user/mavgpud.service
# ssh workpc 'systemctl --user daemon-reload && systemctl --user enable --now mavgpud'
# sudo loginctl enable-linger kami
Description=Maven GPU supervisor (holds llama-server while the card is free)
After=network.target
[Service]
ExecStart=%h/.local/bin/mavgpud -config %h/.config/mavgpud.json
Restart=always
RestartSec=5
# The card must come back when the supervisor goes down. mavgpud stops
# llama-server on SIGTERM, so give it longer than stop_grace to do that.
KillSignal=SIGTERM
TimeoutStopSec=60
[Install]
WantedBy=default.target
+9
View File
@@ -79,7 +79,16 @@ services:
<<: *image
# voice.bind is 0.0.0.0:9100 in deploy/mavend.json so mavweb can reach it
# 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",
"-ambient-token", "${MAVEN_AMBIENT_TOKEN:-}",
"-nexus", "http://nexus:9740", "-praxis", "http://praxis:8989", "-hexis", "http://hexis:9741"]
depends_on: [mavend]
# loopback-only on purpose: /tools defines+executes arbitrary argv and
+142
View File
@@ -0,0 +1,142 @@
# 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.
Contention is decided by presence under `/sys/class/kfd/kfd/proc`, not by a VRAM
threshold. A ROCm process registers there when it initialises HIP, before it
allocates anything. So the supervisor sees a contender during that job's startup,
and yields before the job loses the memory it asked for. A
threshold reads the card too late. By the time free VRAM has dropped, the other
job has already lost the allocation race. Free VRAM is still read, but only as a
precondition for loading, never as the eviction signal. One blind spot is known.
A job can take the card without registering on the KFD, as a Vulkan or a
video-decode job would. `describe()` logs every contender's comm, and that log is
how we find out whether the blind spot is real.
`mavgpud` runs from a systemd unit on the workstation with
`deploy/mavgpud.json` as its config, and `llama_args` is passed to llama-server
untouched. The model, the context size, the layer count and the MTP flags are the
owner's business and not this daemon's schema.
## 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.
+415 -44
View File
@@ -1,19 +1,62 @@
# QA plan: checking Maven properly
*Last verified: 2026-08-02 @ 7079a24. Living doc: correct it in place, do not append.*
*Last verified: 2026-08-02 @ 20aa2d5. 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.
Refreshed 2026-08-02 against the live list, after PRs #85-#90.
44 of the 50 open Vikunja tasks are `QA:` tasks. They are verification work, not
42 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
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
downstream assumes the voice loop works, and nobody has confirmed that since
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
Two things bite anyone running these checks on homesrv.
@@ -36,45 +79,75 @@ Nothing here has been confirmed since the redeploy, and everything else assumes
it works. Do this first.
Closes or advances: **44** (conversation), **45** (text chat), **287** (voice
session quality), **321** steps 3-5 (quiet mode), **288** (STT fixtures).
session quality), **321** steps 3-5 (quiet mode), **288** (STT golden audio).
**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.
Watch for three things: she answers in feminine forms (`рада`, `поняла`), she
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
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
broke.
3. Say `тихий режим`. Expect `тихий режим включён. буду реже напоминать.`
4. Say `выключи тихий режим`. Expect `тихий режим выключен.` Negation must win.
3. Say `тихий режим`. Expect `тихий режим включён. буду реже напоминать.` **Passes.**
4. Say `выключи тихий режим`. Expect `тихий режим выключен.` Negation must win. **Passes.**
5. Say `в комнате тихо`. Quiet mode must NOT flip. Confirm on `/history` that no
`quiet_hours` fact was written.
`quiet_hours` fact was written. **Passes**: no row written. She answers `пока
не умею отвечать на этот вопрос.`, so it lands on `IntentSystem` with no arm.
6. Say `включи режим тишины`, then `сделай потише`. Both must flip quiet mode
on. These are the noun form and the comparative, added 01-08-2026.
on. These are the noun form and the comparative, added 01-08-2026. **Both pass.**
7. Wait for a nudge, then say `потом` within twenty minutes. Expect `хорошо,
вернусь к этому позже.` and the nudge row on `/notifications` reading
`snoozed`. Say `потом` again with nothing pending: it must route as an
ordinary utterance, not be swallowed.
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
`готово`; 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
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.
**319 is fixed** (01-08-2026). Single-word Russian utterances no longer come
**The wake path cannot be checked as deployed.** `mavwaked` and `mavenclient`
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 `поужинал`
both pass now: `thinSingleToken` spares social singles and any token carrying a
verb ending, and only thins a bare nominal like `вода`. If a one-word utterance
still gets clarified during the smoke test, that is a new case for the lexicon,
not the old bug.
verb ending, and only thins a bare nominal like `вода`. A one-word utterance that
still gets clarified in this session is a new case for the lexicon, not the old bug.
---
## Session 2: measurement (half a day, mostly waiting)
Closes or advances: **320** items 2-4, **278** (make the eval lab routine),
**319** (gate recalibration).
Closes or advances: **320** items 2-4, **278** (make the eval lab routine).
Also **248** (memory evaluation), **319** (the margin gate) and **323** (the
startup timeout arm).
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
@@ -99,14 +172,67 @@ make eval-recall
A large miss against 72.7% means the deploy differs from the bench harness.
Two things to decide while the numbers are in front of you:
**Run on 02-08-2026 @ af9d213. The deploy matches the bench.** `eval-models`
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.
- **319's gate recalibration.** The single-token rule needs narrowing or
dropping. This needs your judgement, not a threshold sweep. The fixture and the
daemon disagree about what is correct on two of the three false clarifies.
Two numbers in this repo were wrong, and both flattered the resident model.
- **The classifier is not 36.8% and not 31ms.** `make eval-router` reports
`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
is built. Decide whether it runs on a timer, on every merge, or on demand, and
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`
pid under `maven-mavend-1`, post a turn, and confirm it still completes
@@ -115,47 +241,269 @@ check that the failure floor catches a mid-session model death.
---
## Session 3: the interaction batch (a day, or three sittings)
## Session 3: the interaction batch (a day, or five sittings)
These need real use rather than a command, grouped by what one sitting covers.
**Morning and delivery** (**280**, **281**, **128**, **282**): open `/morning`,
walk the seven required behaviours, then check the four interruption outcomes
and the digest gap. **282** needs the `desk_active` script enabled on the desk
PC first, which is **15** and needs you at that machine.
**Morning and delivery** (**280**, **281**, **128**, **282**, **283**, **285**):
open `/morning`, walk the seven required behaviours, then check the four
interruption outcomes and the digest gap. **282** needs the `desk_active` script
enabled on the desk 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
task by voice, confirm it lands, check prioritisation ordering is not nonsense.
**246** (mail reader) also exercises the `IngestMail` rung that moved to
`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
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
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
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.
logged clean at boot.
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 (one sitting, no box needed)
## Housekeeping (done 02-08-2026, and this section was mostly wrong)
Four QA tasks will not close no matter how long they sit, because they are
gated on something that does not exist:
This section claimed eleven tasks were not verification work. **Three were not.
The other eight are.** Every one of the eight has shipped, tested code behind it.
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.
- **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.
Relabelled to `Blocked:`, claim verified:
Relabel these so they stop reading as backlog. They are not verification work
that is pending, they are work that has not started.
- **125** zenmoney. `internal/zenmoney/` ships and is tested against a fixture.
`deploy/zenmoney.token` does not exist and the compose mount is commented out.
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.
Same treatment for the five plan-only tasks (**251** MCP, **252** vision,
**253** hearing, **255** speaker recognition, **259** crawler). A `QA:` prefix on
a plan is misleading.
Left alone, because the claim here was false:
- **284** simulator. `cmd/mavend/simulator_test.go`, three scenarios under
`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.
---
@@ -171,16 +519,39 @@ 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. |
| 357 | Decide whether entity-existence validation is the permanent target guard or whether blessing lands in Nexus. |
| 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. 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**. |
| — | Decide on `-require-stepup`. Making it the default needs WebAuthn configured first, or it locks you out of your own admin surfaces. |
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
1. Session 1. If the voice loop is broken, nothing else matters.
2. The `-require-stepup` and Kuma decisions. Five minutes, unblocks **317** fully
and **16**.
3. Session 2. The numbers tell you whether the router is worth its 90x latency.
2. The `-require-stepup` and Kuma decisions. Five minutes, and it unblocks **16**.
3. Session 2. **Run on 02-08-2026.** The numbers came back worse for the router
than the docs claimed. The classifier is 68.8%, not 36.8%, and 16.6µs, not
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.
5. Session 3, split whichever way suits you.
5. Session 3, split whichever way suits you. All five sittings ran on
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.
+24 -13
View File
@@ -8,13 +8,15 @@ import (
"net"
"sync"
"time"
"github.com/kami/maven/internal/netaddr"
)
// Client — the module side of the boundary. Wraps a unix-socket connection
// and satisfies CoreAPI, so a module imports ipc, holds a CoreAPI, and is
// agnostic to whether it's been wired in-process (tests / daemon-embedded)
// or over this socket (full topology). The swappability is the seam auth
// will insert into without touching module code.
// Client — the module side of the boundary. Wraps a connection to core and
// satisfies CoreAPI, so a module imports ipc, holds a CoreAPI, and is
// agnostic to whether it's been wired in-process (tests / daemon-embedded),
// over a local unix socket, or over tcp to another host. The swappability is
// the seam auth will insert into without touching module code.
//
// One Client ⇒ one conn ⇒ one concurrent request at a time. A module that
// wants parallel requests opens one Client per goroutine; the store is the
@@ -22,7 +24,8 @@ import (
// per-Client lock keeps frame interleaving impossible by construction.
type Client struct {
conn net.Conn
path string // kept so a dropped conn can be re-dialed (core restart)
path string // the address as configured, kept for errors and logs
addr netaddr.Addr // parsed, so a dropped conn can be re-dialed (core restart)
mu sync.Mutex
}
@@ -81,14 +84,22 @@ var readOnlyMethods = map[Method]bool{
MethodPing: true,
}
// Dial connects to a core socket at path and returns a Client. The module
// owns its Client lifecycle; Close on shutdown.
// Dial connects to core at path and returns a Client. The module owns its
// Client lifecycle; Close on shutdown.
//
// path is a netaddr seam address: a bare path is the unix socket it has
// always been, and "tcp://host:port?token=..." reaches a core on another
// host. See internal/netaddr.
func Dial(path string) (*Client, error) {
c, err := net.Dial("unix", path)
addr, err := netaddr.Parse(path)
if err != nil {
return nil, fmt.Errorf("ipc: dial %s: %w", path, err)
return nil, err
}
return &Client{conn: c, path: path}, nil
c, err := netaddr.Dial(addr)
if err != nil {
return nil, fmt.Errorf("ipc: dial %s: %w", addr, err)
}
return &Client{conn: c, path: path, addr: addr}, nil
}
func (c *Client) Close() error {
@@ -189,9 +200,9 @@ func (c *Client) call(ctx context.Context, m Method, params, result any) error {
// re-dials clean. Caller holds c.mu.
func (c *Client) roundtrip(m Method, raw json.RawMessage, resp *Response) error {
if c.conn == nil {
conn, err := net.Dial("unix", c.path)
conn, err := netaddr.Dial(c.addr)
if err != nil {
return fmt.Errorf("%w: dial %s: %v", errWriteLost, c.path, err)
return fmt.Errorf("%w: dial %s: %v", errWriteLost, c.addr, err)
}
c.conn = conn
}
+20 -40
View File
@@ -8,11 +8,11 @@ import (
"fmt"
"log"
"net"
"os"
"sync"
"sync/atomic"
"time"
"github.com/kami/maven/internal/netaddr"
"github.com/kami/maven/internal/store"
"golang.org/x/sys/unix"
)
@@ -446,6 +446,7 @@ func mapErr(err error) error {
type Server struct {
api atomic.Value // stores CoreAPI
path string
addr netaddr.Addr
ln net.Listener
wg sync.WaitGroup
@@ -610,31 +611,29 @@ type CheckFunc func(ctx context.Context, m Method, params json.RawMessage) error
// MethodAssertStepUp dispatch calls this instead of going through CoreAPI.
type StepUpFunc func(ctx context.Context) error
// Listen creates a Server bound to path. path's parent dir must exist and be
// 0700 (we chmod it if we own it); the socket file itself is created 0600 so
// only the same unix user can connect — the current "auth floor", same radius
// as wg at the network boundary. Removing a stale socket at path first lets
// the daemon restart cleanly.
// Listen creates a Server bound to path.
//
// A bare path is a unix socket, unchanged: its parent dir is 0700 and the
// socket file itself is 0600, so only the same unix user can connect — the
// current "auth floor", same radius as wg at the network boundary. A stale
// socket is removed first so the daemon restarts cleanly.
//
// A "tcp://host:port?token=..." address binds a network listener instead, for
// a module that lives on another host. There is no filesystem there to be the
// auth floor, so netaddr checks the shared token before this package sees the
// connection and a token is mandatory. See internal/netaddr.
func Listen(path string, api CoreAPI) (*Server, error) {
_ = os.Remove(path) // stale socket from a crashed daemon; ignore missing
if err := os.MkdirAll(parentDir(path), 0o700); err != nil {
return nil, fmt.Errorf("ipc: mkdir socket dir: %w", err)
}
// umask could widen the perms on socket creation; tighten then chmod to
// be explicit. 0600 ⇒ read+write by owner only.
oldMask := unix.Umask(0o077)
ln, err := net.Listen("unix", path)
unix.Umask(oldMask)
addr, err := netaddr.Parse(path)
if err != nil {
return nil, fmt.Errorf("ipc: listen %s: %w", path, err)
return nil, err
}
if err := os.Chmod(path, 0o600); err != nil {
_ = ln.Close()
_ = os.Remove(path)
return nil, fmt.Errorf("ipc: chmod socket: %w", err)
ln, err := netaddr.Listen(addr)
if err != nil {
return nil, err
}
s := &Server{
path: path,
addr: addr,
ln: ln,
done: make(chan struct{}),
}
@@ -1268,7 +1267,7 @@ func (s *Server) Close() error {
// missing the seal costs every write since the last clean shutdown.
log.Printf("ipc: %d connection(s) still busy after %s, closing anyway", s.liveConns(), closeGrace)
}
_ = os.Remove(s.path)
netaddr.Cleanup(s.addr)
return err
}
@@ -1338,25 +1337,6 @@ func (s *Server) Path() string { return s.path }
// while the server is serving (dispatch loads api once per request via atomic).
func (s *Server) SetAPI(api CoreAPI) { s.api.Store(api) }
func parentDir(p string) string {
if i := lastIndexByte(p, '/'); i >= 0 {
if i == 0 {
return "/"
}
return p[:i]
}
return "."
}
func lastIndexByte(s string, b byte) int {
for i := len(s) - 1; i >= 0; i-- {
if s[i] == b {
return i
}
}
return -1
}
// peerCaller — read SO_PEERCRED off a unix conn to identify the connecting
// process. Returns ok=false on a non-unix conn or a platform without
// SO_PEERCRED; the caller then proceeds without a Caller (the socket perms
+193
View File
@@ -0,0 +1,193 @@
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
}
+210
View File
@@ -0,0 +1,210 @@
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)
}
}
+277
View File
@@ -0,0 +1,277 @@
// Package netaddr parses a daemon seam address and dials or binds it.
//
// Every seam between Maven's daemons used to be a unix socket with the
// network hardcoded at the call site — two dials in internal/ipc, one listen,
// and the same pair again in internal/worker. That is correct for co-located
// daemons and it is the reason a module cannot live on another host. This
// package moves the choice into the address string so a deploy picks the
// transport, not a recompile:
//
// /run/maven/stt.sock unix (the default, unchanged)
// unix:///run/maven/stt.sock unix (explicit, same thing)
// tcp://workstation:9310?token=hunter2 tcp
//
// A scheme-less address is unix and behaves exactly as it did before this
// package existed: same 0700 parent dir, same 0600 socket, same bytes on the
// wire with no handshake in front of them.
//
// Over TCP the filesystem permission that authenticated the unix socket is
// gone, and what crosses this seam is audio of the owner speaking and the
// text of his turns. So a TCP seam carries a shared token, checked before the
// first protocol frame is read. Wireguard is supported underneath and is not
// required.
package netaddr
import (
"crypto/subtle"
"errors"
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"golang.org/x/sys/unix"
)
// ErrUnauthorized — the peer presented a token the listener does not accept,
// or presented none when one is required.
var ErrUnauthorized = errors.New("netaddr: unauthorized")
// Addr is a parsed seam endpoint.
type Addr struct {
// Network is "unix" or "tcp".
Network string
// Address is the socket path (unix) or host:port (tcp).
Address string
// Token is the shared secret for a tcp seam. Empty for unix, where the
// filesystem does the same job.
Token string
}
// String renders the address for logs and errors. The token is never included.
func (a Addr) String() string {
if a.Network == "unix" {
return a.Address
}
return a.Network + "://" + a.Address
}
// IsUnix reports whether this seam is a unix socket, and so is local, is
// authenticated by file permissions, and needs no handshake.
func (a Addr) IsUnix() bool { return a.Network == "unix" }
// Parse reads a seam address. Anything without a "scheme://" prefix is a unix
// socket path, which keeps every existing config and every default working
// untouched.
func Parse(s string) (Addr, error) {
if !strings.Contains(s, "://") {
return Addr{Network: "unix", Address: s}, nil
}
u, err := url.Parse(s)
if err != nil {
return Addr{}, fmt.Errorf("netaddr: parse %q: %w", s, err)
}
switch u.Scheme {
case "unix":
return Addr{Network: "unix", Address: u.Path}, nil
case "tcp":
if u.Host == "" {
return Addr{}, fmt.Errorf("netaddr: %q has no host:port", s)
}
return Addr{Network: "tcp", Address: u.Host, Token: u.Query().Get("token")}, nil
default:
return Addr{}, fmt.Errorf("netaddr: unsupported scheme %q", u.Scheme)
}
}
// MustParse is Parse for a literal known good at compile time. It panics on a
// bad address, so use it in tests and constants, never on config input.
func MustParse(s string) Addr {
a, err := Parse(s)
if err != nil {
panic(err)
}
return a
}
// handshakeTimeout bounds the token exchange. A peer that cannot write one
// short line in this long is not going to serve a turn either.
const handshakeTimeout = 5 * time.Second
// greeting prefixes the token line. Versioned so a later mTLS seam can be
// told apart from this one on the wire.
const greeting = "MAVEN1 "
// Dial connects to a. On a tcp seam it sends the token and waits for the
// listener to accept it, so a returned conn is already authorized and the
// caller can write its first protocol frame.
func Dial(a Addr) (net.Conn, error) {
return DialTimeout(a, 0)
}
// DialTimeout is Dial with a bound on the connect. Zero means the operating
// system default. The token exchange gets its own timeout either way.
func DialTimeout(a Addr, timeout time.Duration) (net.Conn, error) {
var c net.Conn
var err error
if timeout > 0 {
c, err = net.DialTimeout(a.Network, a.Address, timeout)
} else {
c, err = net.Dial(a.Network, a.Address)
}
if err != nil {
return nil, err
}
if a.IsUnix() {
return c, nil
}
if err := clientHandshake(c, a.Token); err != nil {
_ = c.Close()
return nil, err
}
return c, nil
}
func clientHandshake(c net.Conn, token string) error {
_ = c.SetDeadline(time.Now().Add(handshakeTimeout))
defer c.SetDeadline(time.Time{})
if _, err := c.Write([]byte(greeting + token + "\n")); err != nil {
return fmt.Errorf("netaddr: send token: %w", err)
}
var reply [3]byte
if _, err := readFull(c, reply[:]); err != nil {
return fmt.Errorf("%w: %v", ErrUnauthorized, err)
}
if string(reply[:]) != "ok\n" {
return ErrUnauthorized
}
return nil
}
// Listener wraps a net.Listener so Accept performs the token check for a tcp
// seam. A connection that fails the check is closed and never surfaces, so
// the protocol above this layer only ever sees authorized peers.
type Listener struct {
net.Listener
addr Addr
}
// Accept returns the next authorized connection. Unauthorized peers are
// dropped and Accept keeps waiting: a bad token is a rejected stranger, not a
// reason to stop serving.
func (l *Listener) Accept() (net.Conn, error) {
for {
c, err := l.Listener.Accept()
if err != nil {
return nil, err
}
if l.addr.IsUnix() {
return c, nil
}
if err := serverHandshake(c, l.addr.Token); err != nil {
_ = c.Close()
continue
}
return c, nil
}
}
// Addr reports the parsed seam address this listener was built from.
func (l *Listener) SeamAddr() Addr { return l.addr }
func serverHandshake(c net.Conn, want string) error {
_ = c.SetDeadline(time.Now().Add(handshakeTimeout))
defer c.SetDeadline(time.Time{})
// The line is bounded: greeting, token, newline. Read a byte at a time so
// nothing of the first protocol frame is consumed when the token is short.
line := make([]byte, 0, 128)
var b [1]byte
for {
if _, err := readFull(c, b[:]); err != nil {
return err
}
if b[0] == '\n' {
break
}
line = append(line, b[0])
if len(line) > 512 {
return ErrUnauthorized
}
}
got, ok := strings.CutPrefix(string(line), greeting)
if !ok {
return ErrUnauthorized
}
if subtle.ConstantTimeCompare([]byte(got), []byte(want)) != 1 {
return ErrUnauthorized
}
if _, err := c.Write([]byte("ok\n")); err != nil {
return err
}
return nil
}
func readFull(c net.Conn, p []byte) (int, error) {
n := 0
for n < len(p) {
m, err := c.Read(p[n:])
n += m
if err != nil {
return n, err
}
}
return n, nil
}
// Listen binds a. A unix seam gets the perms it has always had: parent dir
// 0700, socket 0600, and any stale socket from a crashed daemon removed
// first. A tcp seam must carry a token, because there is no filesystem to
// stand in for one.
func Listen(a Addr) (*Listener, error) {
if a.IsUnix() {
ln, err := listenUnix(a.Address)
if err != nil {
return nil, err
}
return &Listener{Listener: ln, addr: a}, nil
}
if a.Token == "" {
return nil, fmt.Errorf("netaddr: listen %s: tcp seam requires a token", a)
}
ln, err := net.Listen("tcp", a.Address)
if err != nil {
return nil, fmt.Errorf("netaddr: listen %s: %w", a, err)
}
return &Listener{Listener: ln, addr: a}, nil
}
func listenUnix(path string) (net.Listener, error) {
_ = os.Remove(path) // stale socket from a crashed daemon; ignore missing
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, fmt.Errorf("netaddr: mkdir socket dir: %w", err)
}
// umask could widen the perms on socket creation; tighten then chmod to
// be explicit. 0600 ⇒ read+write by owner only.
oldMask := unix.Umask(0o077)
ln, err := net.Listen("unix", path)
unix.Umask(oldMask)
if err != nil {
return nil, fmt.Errorf("netaddr: listen %s: %w", path, err)
}
if err := os.Chmod(path, 0o600); err != nil {
_ = ln.Close()
_ = os.Remove(path)
return nil, fmt.Errorf("netaddr: chmod socket: %w", err)
}
return ln, nil
}
// Cleanup removes the socket file behind a unix seam. It is a no-op for tcp.
func Cleanup(a Addr) {
if a.IsUnix() && a.Address != "" {
_ = os.Remove(a.Address)
}
}
+185
View File
@@ -0,0 +1,185 @@
package netaddr
import (
"errors"
"net"
"path/filepath"
"testing"
)
// A scheme-less address must stay unix. Every deploy in the tree writes a bare
// path, so this is the test that says the transport change costs them nothing.
func TestParseSchemelessIsUnix(t *testing.T) {
a, err := Parse("/run/maven/stt.sock")
if err != nil {
t.Fatalf("parse: %v", err)
}
if !a.IsUnix() {
t.Fatalf("want unix, got %q", a.Network)
}
if a.Address != "/run/maven/stt.sock" {
t.Fatalf("address = %q", a.Address)
}
if a.Token != "" {
t.Fatalf("unix seam carries a token: %q", a.Token)
}
}
func TestParse(t *testing.T) {
cases := []struct {
in string
net, addr, tk string
wantErr bool
}{
{in: "", net: "unix", addr: ""},
{in: "unix:///run/maven/core.sock", net: "unix", addr: "/run/maven/core.sock"},
{in: "tcp://workstation:9310", net: "tcp", addr: "workstation:9310"},
{in: "tcp://workstation:9310?token=hunter2", net: "tcp", addr: "workstation:9310", tk: "hunter2"},
{in: "tcp://", wantErr: true},
{in: "udp://workstation:9310", wantErr: true},
}
for _, c := range cases {
a, err := Parse(c.in)
if c.wantErr {
if err == nil {
t.Errorf("Parse(%q) = %v, want error", c.in, a)
}
continue
}
if err != nil {
t.Errorf("Parse(%q): %v", c.in, err)
continue
}
if a.Network != c.net || a.Address != c.addr || a.Token != c.tk {
t.Errorf("Parse(%q) = %+v, want %s/%s/%s", c.in, a, c.net, c.addr, c.tk)
}
}
}
// The token must never reach a log line.
func TestStringHidesToken(t *testing.T) {
a := MustParse("tcp://workstation:9310?token=hunter2")
if got := a.String(); got != "tcp://workstation:9310" {
t.Fatalf("String() = %q", got)
}
}
// A unix seam must round-trip with no handshake in front of the payload: the
// first bytes the listener sees are the caller's, exactly as before.
func TestUnixRoundTripHasNoHandshake(t *testing.T) {
a := MustParse(filepath.Join(t.TempDir(), "s.sock"))
ln, err := Listen(a)
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
go echoOnce(ln)
c, err := Dial(a)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer c.Close()
if got := roundTrip(t, c, "hello"); got != "hello" {
t.Fatalf("got %q", got)
}
}
func TestTCPRoundTripWithToken(t *testing.T) {
ln, addr := listenLoopback(t, "s3cret")
defer ln.Close()
go echoOnce(ln)
c, err := Dial(addr)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer c.Close()
if got := roundTrip(t, c, "hello"); got != "hello" {
t.Fatalf("got %q", got)
}
}
func TestTCPWrongTokenIsRejected(t *testing.T) {
ln, addr := listenLoopback(t, "s3cret")
defer ln.Close()
// Accept keeps waiting past the bad peer, so nothing here should ever
// reach the echo. A conn that does means the token was not checked.
go echoOnce(ln)
bad := addr
bad.Token = "wrong"
if _, err := Dial(bad); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("dial with wrong token: err = %v, want ErrUnauthorized", err)
}
}
// A stranger that speaks the protocol instead of the greeting is dropped, and
// the listener stays up for the peer that follows it.
func TestTCPUngreetedPeerDoesNotKillTheListener(t *testing.T) {
ln, addr := listenLoopback(t, "s3cret")
defer ln.Close()
go echoOnce(ln)
raw, err := net.Dial("tcp", addr.Address)
if err != nil {
t.Fatalf("raw dial: %v", err)
}
if _, err := raw.Write([]byte("GET / HTTP/1.1\n")); err != nil {
t.Fatalf("raw write: %v", err)
}
raw.Close()
c, err := Dial(addr)
if err != nil {
t.Fatalf("dial after stranger: %v", err)
}
defer c.Close()
if got := roundTrip(t, c, "still here"); got != "still here" {
t.Fatalf("got %q", got)
}
}
// A tcp seam with no token is a misconfiguration, and it must fail at bind
// rather than serve the owner's turns to anyone who connects.
func TestTCPListenRequiresToken(t *testing.T) {
if _, err := Listen(MustParse("tcp://127.0.0.1:0")); err == nil {
t.Fatal("listen on a tokenless tcp seam succeeded")
}
}
func listenLoopback(t *testing.T, token string) (*Listener, Addr) {
t.Helper()
ln, err := Listen(Addr{Network: "tcp", Address: "127.0.0.1:0", Token: token})
if err != nil {
t.Fatalf("listen: %v", err)
}
return ln, Addr{Network: "tcp", Address: ln.Addr().String(), Token: token}
}
func echoOnce(ln *Listener) {
c, err := ln.Accept()
if err != nil {
return
}
defer c.Close()
buf := make([]byte, 256)
n, err := c.Read(buf)
if err != nil {
return
}
_, _ = c.Write(buf[:n])
}
func roundTrip(t *testing.T, c net.Conn, msg string) string {
t.Helper()
if _, err := c.Write([]byte(msg)); err != nil {
t.Fatalf("write: %v", err)
}
buf := make([]byte, 256)
n, err := c.Read(buf)
if err != nil {
t.Fatalf("read: %v", err)
}
return string(buf[:n])
}
+8 -2
View File
@@ -534,8 +534,14 @@ func (p *LLMPhraser) PhraseReminder(ctx context.Context, d loop.ReminderDecision
text = "reminder"
}
// Russian, like the other two prompts (Vikunja #404). Asking a model for a
// Russian reply in English is asking it to switch languages mid-prompt,
// and a 1.7B sometimes answers in the language it was asked in. The
// persona rules and the JSON contract are not repeated here: this call
// goes through chat(), so nudgeSystem already states both, and a second
// statement of the same contract is one more thing that can drift.
prompt := fmt.Sprintf(
`The user set a reminder: "%s". Rephrase it briefly as a gentle nudge. Respond as JSON: {"response": "...", "mood": "..."}`,
`Он поставил напоминание: "%s". Скажи это своими словами, коротко и мягко — одно предложение.`,
text,
)
resp, err := p.chat(ctx, prompt)
@@ -754,7 +760,7 @@ func (p *LLMPhraser) querySystemPrompt() string {
base := "Ты отвечаешь ему по источникам, которые тебе дали. Отвечай ТОЛЬКО по ним: всё, что ты говоришь, должно быть написано в источниках. " +
"Если ответа в них нет — так и скажи и на этом остановись; не добавляй ничего из своих знаний и не догадывайся. " +
"Не приплетай прошлые реплики разговора. " +
"Отвечай по-русски, коротко и своими словами, начинай с \"вот что я нашла: \". О себе — в женском роде, глаголы в прошедшем времени с окончанием -ла. Он мужчина, обращайся к нему на \"ты\". Respond ONLY with valid JSON: {\"response\": \"...\", \"mood\": \"neutral\"}."
"Отвечай по-русски, коротко и своими словами, начинай с \"вот что я нашла: \". О себе — в женском роде, глаголы в прошедшем времени с окончанием -ла. Он мужчина, обращайся к нему на \"ты\". Отвечай ТОЛЬКО одним объектом JSON: {\"response\": \"...\", \"mood\": \"neutral\"}."
return persona.Prepend(p.cfg.ContextBlock, base)
}
+256
View File
@@ -0,0 +1,256 @@
package phraser
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"syscall"
"testing"
"time"
)
// The spawn path (NewLLMPhraser, spawnLlamaServer, startLlamaProc, llamaProc.Close)
// was at 0% coverage: every test built the phraser with NewLLMPhraserAt, which
// starts no process. These tests drive the real spawn code against a fake
// llama-server script, so the startup race arms and the reaping are exercised
// without a model or a GPU.
// fakeLlama writes an executable script standing in for llama-server and returns
// its path. body runs after the script has recorded its own pid.
func fakeLlama(t *testing.T, body string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "fake-llama-server")
script := "#!/bin/sh\n" + body + "\n"
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
t.Fatalf("write fake server: %v", err)
}
return path
}
// listensThenSleeps prints the line startLlamaProc scrapes, then stays alive
// until killed — the shape of a real llama-server that came up.
const listensThenSleeps = `echo "srv load_model: listening on http://127.0.0.1:18081" >&2
while : ; do sleep 1 ; done`
func testCfg(bin string) Config {
cfg := DefaultConfig("/nonexistent/model.gguf")
cfg.BinPath = bin
return cfg
}
func TestExtractPort(t *testing.T) {
for _, tc := range []struct{ in, want string }{
{"127.0.0.1:0", "0"},
{"127.0.0.1:8080", "8080"},
{"127.0.0.1:", "0"},
{"", "0"},
{"8080", "0"}, // no colon: Cut yields no port, so the caller gets the "any port" default
} {
if got := extractPort(tc.in); got != tc.want {
t.Errorf("extractPort(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestStartLlamaProcScrapesPortAndReaps(t *testing.T) {
bin := fakeLlama(t, listensThenSleeps)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
p, err := startLlamaProc(ctx, testCfg(bin))
if err != nil {
t.Fatalf("startLlamaProc: %v", err)
}
if p.BaseURL() != "http://127.0.0.1:18081" {
t.Fatalf("BaseURL = %q, want the scraped address", p.BaseURL())
}
pid := p.cmd.Process.Pid
p.cancel = cancel
if err := p.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
// Close must Wait, otherwise the child lingers as a zombie.
if p.cmd.ProcessState == nil {
t.Fatal("Close did not reap the child: ProcessState is nil")
}
if err := syscall.Kill(pid, 0); err == nil {
t.Fatalf("child %d still exists after Close", pid)
}
}
func TestStartLlamaProcFailureArms(t *testing.T) {
t.Run("binary missing", func(t *testing.T) {
cfg := testCfg(filepath.Join(t.TempDir(), "does-not-exist"))
_, err := startLlamaProc(context.Background(), cfg)
if err == nil || !strings.Contains(err.Error(), "llm: start") {
t.Fatalf("err = %v, want a start failure", err)
}
})
t.Run("server exits without listening", func(t *testing.T) {
// stderr closes, so the reader goroutine reports EOF on errCh.
bin := fakeLlama(t, `echo "ggml_vulkan: no device" >&2
exit 1`)
_, err := startLlamaProc(context.Background(), testCfg(bin))
if err == nil || !strings.Contains(err.Error(), "llm: server output") {
t.Fatalf("err = %v, want the server-output arm", err)
}
})
t.Run("context cancelled during startup", func(t *testing.T) {
// Never prints the listen line and never exits: only ctx can end this.
bin := fakeLlama(t, `while : ; do sleep 1 ; done`)
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(150 * time.Millisecond)
cancel()
}()
defer cancel()
_, err := startLlamaProc(ctx, testCfg(bin))
if !errors.Is(err, context.Canceled) {
t.Fatalf("err = %v, want context.Canceled", err)
}
})
}
func TestNewLLMPhraserSpawns(t *testing.T) {
bin := fakeLlama(t, listensThenSleeps)
p, err := NewLLMPhraser(context.Background(), testCfg(bin))
if err != nil {
t.Fatalf("NewLLMPhraser: %v", err)
}
if p.BaseURL() != "http://127.0.0.1:18081" {
t.Fatalf("BaseURL = %q", p.BaseURL())
}
pid := p.be.(*llamaProc).cmd.Process.Pid
if err := p.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
if p.BaseURL() != "" {
t.Fatalf("BaseURL after Close = %q, want empty", p.BaseURL())
}
if err := syscall.Kill(pid, 0); err == nil {
t.Fatalf("llama-server %d survived Close", pid)
}
}
func TestNewLLMPhraserSpawnFailure(t *testing.T) {
cfg := testCfg(filepath.Join(t.TempDir(), "does-not-exist"))
p, err := NewLLMPhraser(context.Background(), cfg)
if err == nil {
p.Close()
t.Fatal("want an error when the server cannot start")
}
if p != nil {
t.Fatalf("want a nil phraser on failure, got %#v", p)
}
}
// TestPdeathsigKillsOrphan is the orphan test the task asked for. A SIGKILLed
// mavend never runs Close, so nothing but the kernel's Pdeathsig can stop its
// llama-server. Re-exec this test binary as the "daemon", let it spawn the fake
// server, SIGKILL the daemon, and assert the grandchild died with it.
func TestPdeathsigKillsOrphan(t *testing.T) {
bin := fakeLlama(t, listensThenSleeps)
cmd := exec.Command(os.Args[0], "-test.run=TestSpawnHelperProcess", "-test.v=false")
cmd.Env = append(os.Environ(), "MAVEN_SPAWN_HELPER=1", "MAVEN_FAKE_LLAMA="+bin)
out, err := cmd.StdoutPipe()
if err != nil {
t.Fatalf("stdout pipe: %v", err)
}
if err := cmd.Start(); err != nil {
t.Fatalf("start helper: %v", err)
}
defer func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }()
buf := make([]byte, 256)
n, err := out.Read(buf)
if err != nil {
t.Fatalf("read child pid: %v", err)
}
childPID, err := strconv.Atoi(strings.TrimSpace(string(buf[:n])))
if err != nil {
t.Fatalf("helper printed %q, want a pid: %v", string(buf[:n]), err)
}
if err := syscall.Kill(childPID, 0); err != nil {
t.Fatalf("llama-server %d not running before the kill: %v", childPID, err)
}
// SIGKILL: the helper gets no chance to clean up, exactly like an OOM kill.
if err := cmd.Process.Signal(syscall.SIGKILL); err != nil {
t.Fatalf("kill helper: %v", err)
}
_, _ = cmd.Process.Wait()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if err := syscall.Kill(childPID, 0); err != nil {
return // gone: Pdeathsig did its job
}
time.Sleep(20 * time.Millisecond)
}
_ = syscall.Kill(childPID, syscall.SIGKILL)
t.Fatalf("llama-server %d outlived the SIGKILLed parent", childPID)
}
// TestSpawnHelperProcess is not a test. It is the child half of
// TestPdeathsigKillsOrphan: spawn a llama-server, print its pid, then block.
func TestSpawnHelperProcess(t *testing.T) {
if os.Getenv("MAVEN_SPAWN_HELPER") != "1" {
t.Skip("helper for TestPdeathsigKillsOrphan")
}
cfg := testCfg(os.Getenv("MAVEN_FAKE_LLAMA"))
p, err := startLlamaProc(context.Background(), cfg)
if err != nil {
fmt.Println("spawn failed:", err)
os.Exit(1)
}
fmt.Println(p.cmd.Process.Pid)
os.Stdout.Sync()
select {} // wait to be killed
}
// TestKillMavenScriptMatchesRealCommandLine pins kill-maven.sh's fallback
// pattern to the command line startLlamaProc actually builds. The script leaked
// orphans twice already, both times because the pattern stopped matching: first
// `llama-server.*maven`, then a hardcoded model name after the model was swapped.
func TestKillMavenScriptMatchesRealCommandLine(t *testing.T) {
src, err := os.ReadFile("../../kill-maven.sh")
if err != nil {
t.Fatalf("read kill-maven.sh: %v", err)
}
m := regexp.MustCompile(`(?m)^\s*LLM='([^']+)'`).FindSubmatch(src)
if m == nil {
t.Fatal("no default LLM='...' pattern in kill-maven.sh")
}
pat, err := regexp.Compile(string(m[1]))
if err != nil {
t.Fatalf("LLM pattern %q does not compile: %v", m[1], err)
}
// Rebuild the command line from the production arg list, so a change to
// startLlamaProc that breaks the sweep fails here instead of on the box.
cfg := DefaultConfig("/opt/maven/models/llm/Qwen3-1.7B-UD-Q4_K_XL.gguf")
cfg.NCtx, cfg.NGpuLayers = 4096, 99
cmdline := strings.Join([]string{
cfg.BinPath,
"-m", cfg.ModelPath,
"--host", "127.0.0.1",
"--port", extractPort(cfg.Listen),
"-c", fmt.Sprintf("%d", cfg.NCtx),
"-ngl", fmt.Sprintf("%d", cfg.NGpuLayers),
"--no-webui",
}, " ")
if !pat.MatchString(cmdline) {
t.Fatalf("kill-maven.sh pattern %q does not match %q — orphans would leak", m[1], cmdline)
}
}
+1 -1
View File
@@ -6,5 +6,5 @@ func KnowledgePrompt() string {
// No self-introduction here: the shared persona block already says who she
// is, and this line used to disagree with it — a different name ("Мавена")
// and a masculine noun ("ассистент") in front of a feminine persona.
return `Ответь кратко из своих знаний. Если не знаешь — скажи "не знаю". Не выдумывай. Respond ONLY with valid JSON: {"response": "...", "mood": "neutral"}.`
return `Ответь кратко из своих знаний. Если не знаешь — скажи "не знаю". Не выдумывай. Отвечай ТОЛЬКО одним объектом JSON: {"response": "...", "mood": "neutral"}.`
}
+14 -2
View File
@@ -24,6 +24,8 @@ import (
"net"
"sync"
"time"
"github.com/kami/maven/internal/netaddr"
)
// Client — one connection to one worker module. NOT goroutine-safe for
@@ -38,15 +40,25 @@ type Client struct {
dial func() (net.Conn, error)
}
// Dial opens a Client to the worker socket at path. The first call lazily
// Dial opens a Client to the worker module at path. The first call lazily
// dials; subsequent calls reuse the conn (a fresh dial happens on next call
// after a teardown). Lazy dial keeps a worker that's restarting from
// blocking core's startup; core attempts the dial on first use.
//
// path is a netaddr seam address. A bare path is the unix socket it has
// always been; "tcp://workstation:9310?token=..." reaches a module on another
// host, which is how stt and tts move to the machine with the GPU and the
// microphone. A bad address surfaces on the first call, not here, because
// Dial does not fail — see internal/netaddr.
func Dial(path string) *Client {
addr, err := netaddr.Parse(path)
return &Client{
path: path,
dial: func() (net.Conn, error) {
return net.Dial("unix", path)
if err != nil {
return nil, err
}
return netaddr.Dial(addr)
},
}
}
+22 -34
View File
@@ -7,10 +7,11 @@
// which module to dial; mixing the two is a config error caught cleanly by
// the wire, not a runtime goroutine panic). One Server per module process.
//
// Socket perms mirror ipc.Server: dir 0700, socket 0600 ⇒ same unix user.
// The module has no key, so the floor is "same user"; the wg/mTLS layers
// are out of scope here (this socket never crosses the network radius —
// it's local-only, point-to-point between two processes on the box).
// The seam address decides the transport. On the default unix socket the
// perms mirror ipc.Server — dir 0700, socket 0600 ⇒ same unix user — and that
// is the whole auth floor, because the seam never leaves the box. A tcp
// address moves the module to another host and takes that floor away, so
// netaddr checks a shared token before the first frame. See internal/netaddr.
package worker
import (
@@ -18,11 +19,10 @@ import (
"encoding/json"
"fmt"
"net"
"os"
"sync"
"sync/atomic"
"golang.org/x/sys/unix"
"github.com/kami/maven/internal/netaddr"
)
// Server — a worker module process's listener. Wires either a Transcriber,
@@ -34,6 +34,7 @@ type Server struct {
s Synthesizer
path string
addr netaddr.Addr
ln net.Listener
wg sync.WaitGroup
@@ -61,25 +62,24 @@ func NewSynthesizerServer(path string, s Synthesizer) *Server {
// two separate processes per the restart-free / fail-independent invariant).
func (srv *Server) SetSynthesizer(s Synthesizer) { srv.s = s }
// Listen binds the unix socket with 0700 dir + 0600 socket perms (same floor
// as internal/ipc). A stale socket at path is removed first so the worker
// process restarts cleanly after a crash, no manual cleanup needed.
// Listen binds the seam address the Server was built with.
//
// A bare path is a unix socket with 0700 dir + 0600 socket perms, the same
// floor as internal/ipc, and a stale socket is removed first so the worker
// process restarts cleanly after a crash. A "tcp://host:port?token=..."
// address binds a network listener instead, so this module can run on the
// workstation while core stays on homesrv; the token is mandatory there,
// because there is no filesystem to be the auth floor. See internal/netaddr.
func (srv *Server) Listen() error {
_ = os.Remove(srv.path)
if err := os.MkdirAll(parentDir(srv.path), 0o700); err != nil {
return fmt.Errorf("worker: mkdir socket dir: %w", err)
}
oldMask := unix.Umask(0o077)
ln, err := net.Listen("unix", srv.path)
unix.Umask(oldMask)
addr, err := netaddr.Parse(srv.path)
if err != nil {
return fmt.Errorf("worker: listen %s: %w", srv.path, err)
return err
}
if err := os.Chmod(srv.path, 0o600); err != nil {
_ = ln.Close()
_ = os.Remove(srv.path)
return fmt.Errorf("worker: chmod socket: %w", err)
ln, err := netaddr.Listen(addr)
if err != nil {
return err
}
srv.addr = addr
srv.ln = ln
return nil
}
@@ -193,7 +193,7 @@ func (srv *Server) Close() error {
}
err := srv.ln.Close()
srv.wg.Wait()
_ = os.Remove(srv.path)
netaddr.Cleanup(srv.addr)
return err
}
@@ -214,15 +214,3 @@ func marshalResult(v any) json.RawMessage {
b, _ := json.Marshal(v)
return b
}
func parentDir(p string) string {
for i := len(p) - 1; i >= 0; i-- {
if p[i] == '/' {
if i == 0 {
return "/"
}
return p[:i]
}
}
return "."
}