mavweb (and every ipc.Client) held one net.Conn from Dial and reused it for the
life of the process. When mavend restarted, the socket got a new inode, the
cached conn went dead, and every call failed forever with "broken pipe" — the
dash and page-heartbeat 502'd until mavweb was manually restarted.
Fix in the one place all 25 methods route through (call): on a lost connection
— write failure OR read EOF, since a peer restart can surface on either phase
depending on socket-buffer timing — drop the conn, re-dial the remembered path,
and retry once. Safe for the case that happens (core restarted, request never
processed); the rare committed-then-died window can double-apply a write, but
the store is append-only so a duplicate is a superseding row, not corruption.
ponytail: retry-once, not request-ids — revisit if double-apply ever bites.
Test reproduces the exact incident: server restart on the same socket path, and
asserts the next call transparently reconnects.
Note (not fixed here): Server.Close waits on its handler goroutines, which park
reading live client conns — so a graceful core shutdown with a client attached
blocks until the client disconnects. Minor; surfaces as a slow SIGTERM.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
"voice unavailable" in the web UI: mavweb dials mavend:9100, but the container
mavend.json had no voice block, so mavend never bound 9100 (worked pre-docker
because the host's ~/.config/maven/mavend.json had one). Ported that block:
enabled, bind 0.0.0.0:9100 (not 127.0.0.1 — mavweb is a separate container),
lang ru, stt/tts worker sockets, onnx embedder.
Enabling the embedder surfaced a second bug: the router needs onnxruntime 1.26,
but deps/lib only carries dangling symlinks to it (absolute host paths, not in
the image), so the only libonnxruntime present was piper's 1.14 (copied in) →
"ORT API base: 2", crash loop. Fixed the Dockerfile to ship the real 1.26 .so
and stop copying piper's .so into the shared lib dir (piper finds its own 1.14
via $ORIGIN + exact soname, so TTS is unaffected).
Verified: mavend "onnx embedder loaded (384 dim)", "voice listening on :9100",
stack stable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mapping /dev/dri alone wasn't enough — the container runs as unprivileged
uid 10001, and renderD128 is root:render (mode crw-rw----). Without membership
in the host render gid, Vulkan enumerates zero devices and whisper silently
falls back to CPU. Added group_add 993 (host 'render'); whisper now loads on
RADV RENOIR (AMD Radeon).
Three fixes found bringing the stack up on the host daemon:
- base was golang:1.23-bookworm: bookworm's glibc 2.36 / GLIBCXX 3.4.30 is too
old to link the prebuilt deps/lib/*.so (built on Arch against glibc 2.38 /
GLIBCXX 3.4.32). Moved build+runtime to trixie (glibc 2.40). golang trixie
images start at 1.24, which builds the go 1.23 module fine.
- builder now installs libvulkan-dev — libggml-vulkan.so needs libvulkan.so.1
at link time.
- image renamed maven:latest -> mavenai:latest with pull_policy:never. "maven"
is Apache Maven on Docker Hub; compose was silently pulling it, so every
container ran mvn-entrypoint.sh and exited 127.
Verified on the host: all six build, five-service stack stays up, mavend opens
the encrypted db, mavweb GET :9201 -> 200.
Compose stack replacing start-maven.sh's bare `&`-backgrounded processes.
Single multi-stage image builds all six daemons (CGO + prebuilt native libs
from deps/); compose runs one container each with a different command. Only
mavend mounts the encryption key (env_file, gitignored) and the db volume; the
modules mount just the shared unix-socket dir and read-only models — so the
"key-free modules" boundary is OS-enforced (separate namespaces), not just a
code convention. IPC stays unix-domain over a shared volume: zero code change,
paths move to /run/maven. Encrypted db at rest on a named volume, decrypted
working copy in tmpfs (RAM) per the at-rest encryption landed earlier.
Validated: `docker compose config` clean, mavend.json parses, all daemon flags
confirmed. NOT build-tested (no docker/GPU in authoring env) — deploy/README.md
lists the host-dependent tweak points (GPU passthrough, onnxruntime path,
cross-container voice bind, netdata host).
Chosen Docker over interim systemd units per the "dockerize soon" call — no
throwaway supervisor built.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Recovery already works — DueReminders is (pending AND fire_ts<=now) with no
lower bound and the gatherer calls it every tick, so overdue reminders fire on
the first boot tick. Reclassified to doable-tier "stale-reminder burst collapse"
(cosmetic: avoid a boot-time spam of stale reminders).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- handlers_test.go: first tests for cmd/mavweb (feature-ranking #2). Covers
the /tools enable/disable surface (arg parsing, error mapping, html escaping)
and the webauthn handler contracts (method guards, malformed input). 14 cases.
Verified the enable path is genuinely gated: an un-asserted call fails at the
mavend IPC boundary (Requirement(EnableTool)=AuthStepUp), so mavweb stays a
trust-nothing pass-through and core mediates.
- policy.go: DisableTool now also requires AuthStepUp. It mutates the same tool
allowlist as EnableTool and is a lever to silence a security-relevant tool;
gating allowlist mutation uniformly beats a split rule. ProposeTool stays
maven-callable (no passkey). Corrects the stale api.go comment that claimed
all three gated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two spine infra items (feature-ranking #1, part of the migration prereq):
- migrations.go: PRAGMA user_version runner, empty (no-op) migration slice,
one tx per step, fail-closed. Mechanism in place before any real schema
change needs it.
- crypt.go: file-level at-rest encryption. On-disk file is always AES-256-GCM
ciphertext; decrypted to a tmpfs working copy modernc sqlite operates on;
re-encrypted atomically on Close, plaintext wiped, key zeroed. Pure stdlib,
CGO stays off. Fails closed on wrong key/tamper, never falls back to
plaintext. Key is a 32-byte seam (config db_key_b64/db_key_env today; the
passkey-derived L3 cold-start key plugs into the same seam later).
Chosen over cgo SQLCipher (would force libsqlcipher + CGO across the project)
and over the ncruces page-level VFS (swaps the driver project-wide); noted as
the upgrade path in a ponytail: comment. Threat model is disk-at-rest only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The doc was from the initial commit and predated all open-item work. Update
the works-end-to-end list (protocol doc, away-fallthrough, mavcaldav,
quiet-hours schedule, tools enable/disable, note RAG, passkey step-up), rework
the not-built-yet ranking (at-rest crypto, mavweb/mavcaldav tests, systemd),
and add the rationale for deferring multi-user.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the three in-flight open items and fixes the away-fallthrough bug.
Item 7 — passkey step-up (WebAuthn):
- internal/webauthn: ES256/P-256 register + assert with real ecdsa signature
verification, minimal CBOR/COSE decode, PasskeySession (L2→L3 on assert,
decays after TTL). Drop the RS256 offer we can't verify (register-ok/
assert-fail trap). Verify rpIdHash + UP/UV flags in FinishAssertion — UV is
the step-up gesture. Round-trip test with negative cases (tampered sig,
missing UV, wrong origin).
- cmd/mavweb: /auth/passkey enroll+assert page (the only surface that can do
a WebAuthn gesture) + the four begin/finish endpoints. Without this the
daemon's PasskeySession swap leaves /tools enable permanently blocked.
- daemon wires PasskeySession as the auth Session + srv.StepUp; policy gates
MethodAssertStepUp at AuthRead.
Item 5 — tools page: DisableTool through store/ipc/client/wire; /tools grows a
disable action and a link to the passkey page. Lifecycle test.
Item 6 — note RAG: PhraseQuery on the phraser (LLM-composed answer over top-k
notes, raw-notes fallback); IntentQuery routes through it. Stub returns a
deterministic summary.
Item 2 — away-fallthrough: on ErrVoiceNoSession the dispatcher now reroutes
through the AWAY table (sev3→ntfy, sev4→telegram-repeat-til-ack, sev≤2→drop)
instead of silently dropping / mis-routing to the present-list remainder.
Covers DispatchNudge + DispatchReminder. 4 tests.
Also: re-add ProposeTool to CoreAPI (dropped in a comment rewrite), fix
missing imports + a duplicate block left mid-edit, drop dead AssertStepUpFunc,
gitignore /mavcaldav.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds QuietHours config section (start/end as HH:MM local time). The loop's
gatherer checks the window each tick: if now falls within [start, end), the
State.QuietHours flag is set to true regardless of the config fact (which
the voice toggle writes independently). Both sources activate quiet —
schedule AND toggle.
Handles midnight-crossing windows (23:00-08:00). The gate already reads
State.QuietHours for care nudge suppression — no gate change needed.
Polls Radicale for today's events, writes calendar_busy and calendar_event
facts through CoreAPI. Only writes on value change (same append-only
discipline as mavpoll).
Usage: mavcaldav -socket <core> -url <radicale> -user <u> -pass <p>
Flags: -interval (default 5m), -timeout (default 10s).
Fires immediately on start, then on interval.
iCal parser supports UTC and local DTSTART/DTEND, skips all-day events.
PROTOCOL.md: generated from internal/voice/wire.go — documents the
voice wire format (TCP, length-prefixed JSON, methods, pushes, errors)
for multi-client implementors. Must stay in sync with wire.go.
dispatcher: when voice sink returns ErrVoiceNoSession, skip voice and
continue to remaining channels instead of aborting. sev4-present
already has ntfy in the routing table (continues naturally). sev1-3
present have only voice — the loop ends with no dispatches, which
matches the spec (care/ops-soft drop on no-voice).
voicesink: maps voice.ErrNoSession to delivery.ErrVoiceNoSession so
the dispatcher can detect it without importing the voice package.
The dispatcher already routes sev4-away → telegram. The gap is purely
the runtime ErrNoSession fallthrough in voicesink.go. Points agent at
the existing TODO, tells it to converge not duplicate.
Addresses Claude's review:
- Acceptance criteria (done-when) for all 8 open items
- PROTOCOL.md must be generated from wire.go, not freehand
- Resolved CalDAV: Radicale, new cmd/mavcaldav module
- Reordered to core→capabilities→harden priority
- Hard guard: multi-user schema is do-not-touch this phase
- Dropped Nextcloud mention
piper_handler: close stdin/stdout pipes on Start() failure and on
WriteString error instead of leaking fds. Propagate WriteString error.
worker/client: log SetDeadline errors instead of discarding them.
voice/session: pushAudio marshals params inline and returns the marshal
error instead of swallowing it via mustParams (removed).
tool/matcher: log ListTools errors instead of silently returning an
empty allowlist that refuses every act.
config: applyDefaults now sets RouterThreshold and ToolTimeout defaults
so consumers self-contained defaults are belt-and-suspenders.
worker/server.go: dispatch now receives a per-connection context instead of
context.Background(), so handler cancellation propagates on conn close.
voice/server.go: same — per-conn context fed through safeDispatch into
HandlePushToTalk instead of context.Background().
store/reminders.go: propagate LastInsertId error.
store/nudges.go: propagate LastInsertId and RowsAffected errors.
store/tools.go: propagate RowsAffected error.
config/config.go: applyDefaults now respects StateDir when set, using it as
the base for empty DBPath/SocketPath instead of silently ignoring it.
phraser/llmphraser.go: close stderr pipe fd when cmd.Start() fails.
- resolveQuietToggle runs in HandlePushToTalk before the router so
'тихий режим' works regardless of classifier confidence.
- whisper_full() runs in a goroutine with ctx.Done() select so the
handler returns promptly on timeout/shutdown.
- StubReplier.IntentQuery no longer claims query is unimplemented.