3cc55b4b40e04f9bd7711f37201828b361f1f8c9
844 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
f509668e45 |
docs(gemma4-charrp): record the A16 activation-axis control and how to run it
brokkr-smithy-dev's first battery on the new seat scored 12% on contradiction detection with CoT off, against gen's 81%, while state tracking, deterministic constraint following, long-context state and the confabulation control all sat at 100%. That is not general degradation — it is the shape 4-bit input activations produce on the most reasoning-dense task, which is exactly the confound flagged when the W4A4 quant was chosen. They have recorded the finding as CONFOUNDED rather than reporting it as a property of Gemma, and asked for an A16 build to separate the two. No quant run was needed: a genuine A16 build of the same checkpoint already exists on the Hub and is now at /tank/aimodels/gemma4-26b-a4b-it-nvfp4a16 (prithivMLmods, compressed-tensors, nvfp4-pack-quantized, input_activations null, 17 GB). Same weights, same loader as the live seat, one axis moved. ⚠ Two other repos would have answered the question wrongly and the note in the env template says so: bg-digitalservices and ManniX-ITA both publish "Gemma-4-26B-A4B-it-NVFP4A16" whose config declares input_activations num_bits 4 — W4A4 under an A16 name, via modelopt, both 16.46 GB against the real one's 17.93. Using either and seeing T2 stay at 12% would have wrongly convicted the model. Running it is a one-line GEMMA4_MODEL swap plus a recreate, because there is no room for a concurrent seat: GPU0 has 3.5 GiB free with gen and the A4 seat on it, and GPU1's 19.4 GiB against ~18 GiB of weights is the same sub-GiB headroom that crash-looped the predecessor this morning. Port and both aliases are unchanged either way, so no consumer config moves. |
||
|
|
27155c0f3b |
feat(char-rp): swap the seat to the Gemma-4 26B-A4B MoE, NVFP4, same port
Straight-across replacement of the dense G4-MeroMero-v2-31B-NVFP4A16 seat with google/gemma-4-26B-A4B-it on ana-ml2 GPU0. Port, served-model-names and every gateway route are unchanged, so no consumer sees a difference in addressing: `char-rp` -> hosted_vllm/char-rp and `char-rp-reasoning` -> hosted_vllm/char-rp-thinking, both still :8016. The seat's requirements now include chain-of-thought, which makes throughput more critical rather than less — the user waits through the whole reasoning block before the first visible token, and the MoE measures ~114 tok/s @32K against the dense 31B's ~40.7. Both artifacts are on disk and they are NOT interchangeable. The BF16 weights (/tank/aimodels/gemma4-26b-a4b-it-bf16, 49 GB) are the QLoRA tuning base, since QLoRA does its own quantization. They CANNOT be served here: 48.10 GiB of weights against ~49 GiB of free GPU0 leaves nothing for KV cache, and the engine would die at allocation exactly the way the predecessor did this afternoon. The serving copy is RedHatAI/gemma-4-26B-A4B-it-NVFP4 (16 GB), chosen over the other -it quants because it is compressed-tensors (nvfp4-pack-quantized) — the same loader path the outgoing seat used — from the llm-compressor team at 357k downloads. The nvidia/ repo is the base rather than -it, and the thinking channel lives in the instruction-tuned weights. Smaller weights at the same 0.47 memory budget buy a much larger KV pool: 27.37 GiB and 1,724,110 tokens, against the predecessor's 371,023 at the same budget. That is 6.5 full-length 262K sequences concurrent rather than 1.4. The gemma4 tool-call parser, reasoning parser and the enable_thinking:false default all carry over unchanged — they are architecture-level, not checkpoint-level. The --chat-template override does NOT carry over: MeroMero pointed at a jinja hand-patched against that checkpoint, and this model ships its own. Verified that dropping it did not reintroduce the failure that flag existed to prevent — non-thinking prose lands in content with reasoning_content empty, and the thinking alias populates reasoning_content with content carrying the answer. ⚠ Scheme differs from the incumbent and the bench should say so: this quant declares 4-bit input activations (W4A4) where the outgoing seat was NVFP4A16. Faster, and not like-for-like on the activation axis. meromero-charrp is retained stopped in `created` state and relabelled to AI - Dormant, per the house rollback pattern. Both stacks want :8016, so rolling back means stopping the gemma4 seat first. |
||
|
|
850e0c3351 |
fix(meromero-charrp): drop GPU0 budget to 0.47 — the seat was OOM crash-looping
`vllm-meromero-rp` had been restarting since 2026-08-24 18:2x, 13 times by the time it was looked at, taking both the `char-rp` and `char-rp-reasoning` gateway aliases down with it (they resolve to the same seat on :8016 — hosted_vllm/char-rp and hosted_vllm/char-rp-thinking). Root cause is CUDA OOM on ana-ml2 GPU0, which the startup logs hide well: the engine gets through weights, torch.compile and CUDA-graph capture looking entirely healthy, then dies at KV-cache allocation with `torch.OutOfMemoryError: ... 195.19 MiB is free`. GPU0 is shared with `vllm-gen`. gen is configured at 0.43 but actually holds ~45.6 GiB of the 94.97 GiB card, because --gpu-memory-utilization sizes the KV cache and does not account for CUDA context, graphs and non-torch overhead. This seat was at 0.51, so the pair was committed to 0.94 of the card with about 0.6 GiB of real headroom. That fit on 08-21 and stopped fitting today. 0.47 restores ~4.8 GiB of margin and costs nothing usable: KV cache 27.36 -> 23.56 GiB, 430,825 -> 371,023 tokens against a max-model-len of 262,144, so the pool still holds 1.4x a full-length sequence. What is lost is concurrent long requests, not context. Verified through the gateway rather than at the container: char-rp returns 200 with content, char-rp-reasoning returns 200 with both content and reasoning_content populated. Seat is healthy with RestartCount 0. The arithmetic and the "check used_memory, not the flag" warning are written into the env template, because the next person to raise either budget needs to lower the other in the same change. |
||
|
|
35adc4a043 |
feat(homepage): rebuild on Australis Skyfall — dual theme, light mode shipped
The board was on the Australis TERMINAL palette, which is dark-only by design
("Always dark first. No light mode in this system"). Skyfall is the dual-theme
web derivative of the same science, and its bundle turned out to be sitting in
this repo's own git history: a predecessor vendored it on 2026-08-19 and a
later commit deleted it. `git show 45c1995:...` returns colors.css with both
`:root` (dark) and `[data-theme="light"]` (Skyfall Day) intact, plus the
calm-depth layout tokens, the typography scale and Supreme 400/500/700. So the
light ramp is canonical rather than derived, which was the entire objection to
building one.
The visual language moves with the palette. Depth is now the recipe and not a
choice — every elevated surface carries a 1px hairline AND a two-layer shadow,
never one without the other. Radii move to Skyfall's scale, cards at
--radius-lg. Widget stat values move from the display face to mono, because
Skyfall is explicit that numbers and telemetry are always --font-mono. The
full-width aurora ribbon under the tab bar is gone: Skyfall sanctions exactly
two accent expressions, the active rail and hero-only glows, and a decorative
gradient across the chrome is neither — so the colour it carried now lands on
the active tab as a 2px accent bar plus an --accent-soft fill, which is the
rail. Every binding is written against the semantic layer; there are no raw
family tokens and no colour literals left in our own file.
build.py now guards the vendoring instead of advising it. The three token files
are hashed and a mismatch FAILS the build — a vendored file is either
byte-identical to the bundle or it is a fork wearing the bundle's name, and the
theme this one replaces had to be torn out twice for exactly that.
⚠ Homepage's own theme toggle is unreachable, and reaching for it breaks the
dashboard. It renders only when settings.yaml leaves `theme:` unpinned, and
with the key absent the page's data loader throws and its catch branch serves
`initialSettings: {}` — no tab bar, no layout, no i18n. Six force-recreates
over seven minutes all came up empty; restoring `theme: dark` rendered
correctly on the next recreate in 12 seconds, while /api/services returned 200
with fully correct content the whole time. That is the first confirmed cause of
the long-running "tab bar goes missing after a recreate" symptom, and it also
retires the homepage.log-size lead recorded earlier today: rolling the log
aside did nothing during this episode, so that coincidence was intermittency.
So the toggle is ours. conf/custom.js renders it and stores the choice;
build.py re-emits each vendored light block twice, once for an explicit
`data-theme` and once inside a prefers-color-scheme media query scoped to
`html:not([data-theme="dark"]):not([data-theme="light"])` — that :not() pair is
what lets a stored dark choice survive a light-mode OS. Verified against both
OS preferences: load, click, click back, reload, all four correct. `data-theme`
is the control surface; Homepage's own `dark` class stays on <html> and does
not fight, because our rules carry !important on the surfaces Tailwind's
`dark:` variants would otherwise claim.
Two font substitutions, both documented rather than silent: Space Grotesk for
Bespoke Sans and JetBrains Mono for Victor Mono. Only Supreme was ever vendored
here and Skyfall's own notes call Victor Mono user-supplied, so this is a
two-line swap when the real faces arrive.
Dark and light, all four tabs: http://10.100.10.50:8090/b/homepage-skyfall/
|
||
|
|
39da1d4a97 |
feat(homepage): recategorise on "do I open this?", collapse the API groups
The board mixed tools with endpoints. A vLLM seat whose href is a /docs page
sat in the same band as ComfyUI; the MQTT broker and the RustDesk relay, which
have no page at all, sat in Apps; and `Service Networking` was thirteen members
spanning three AdGuards, five Dockges, two Traefiks and four headless agents.
Every group is now one of two kinds and they never mix. TOOLS are expanded and
sit at the top of their tab. ENDPOINTS — an API, a broker, a background agent,
an href that is /docs or /ping or nothing — carry `initiallyCollapsed: true`
and sit at the bottom. Collapsed is not hidden: the eyebrow and its rule still
render, so the tab still says the thing exists and one click expands it.
A second rule fell out of the same pass and now shapes the group boundaries: a
group's members should all carry a widget or none should. A stat strip makes a
card ~50px taller, so one widget card in a row of plain ones opens a void under
the plain ones. That is why AdGuard and Traefik get their own groups rather
than sharing one with Dockge, and it is most of why the old Service Networking
band looked broken. AdGuard (ANA) was the last short card in its row and now
carries the same query/blocked/latency strip as its two siblings — one
infra-ops AdGuard login authenticates against all three instances, verified
against each; it lives in that stack's .env on the host and is vaulted.
The sixteen GPU-backed model seats were deliberately NOT relabelled.
`homepage.group` is read at container creation, so clearer names for
`AI - Inference` and friends would have cost a recreate on six vLLM seats, four
eval seats and four TTS engines — multi-minute model reloads on endpoints peers
reach through the gateway. Order plus `initiallyCollapsed` buys the same
separation for nothing, so those names stay as they are on purpose.
28 containers that ARE cheap to bounce were relabelled, across five hosts, via
rerunnable elway playbooks. Their label steps are gated on the old value still
being present, so a second run reports skipped rather than churning. Two verify
steps were wrong on first contact and are fixed with the reason recorded: the
traefik check raced its own recreate, and asserting a model seat is "running"
cannot answer "did I bounce it" when a seat may be legitimately stopped —
container age can, and now does.
The canonical stacks/ tree was synced to the deployed labels afterwards, so
intent and reality agree again on all fourteen tracked stacks.
Also documents the real nature of the post-recreate blank dashboard, which cost
~25 minutes here and an hour on 2026-08-19. `initialSettings":{}` in the served
HTML is the catch branch of the page's data loader, not a warm-up and not a
cache — and the error can vanish entirely, because the logger is assigned inside
the same try and the catch only logs if the logger exists. Ruled out by
measurement this time: all four API routes return 200 with correct content while
the page serves {}, and the previous known-good settings.yaml reproduces it
identically. The README now carries the one-command test and the next lead.
Before/after, all four tabs: http://10.100.10.50:8090/b/homepage-relayout/
|
||
|
|
f6f2f69649 |
fix(homepage): uniform 4-column grid, hold the status gutter, unleak AI Systems
The board's card width changed at every group boundary because `columns:` was being tuned per group under the 2026-08-18 "columns = member count" rule. That rule is retired: it sets `lg:grid-cols-N` for one group, so it fixes that group's CARD WIDTH, not its density. Measured on the live board, Notes rendered a single 1464px card, News and Media 728px, Eval & Retrieval 286px, everything else 360px. All 20 groups are now `columns: 4` and every card renders at 360. `.service-name` reserved a 78px status gutter with `padding-right` and relied on `overflow: hidden` to hold it, but overflow clips at the PADDING box — the gutter was spill room the title printed straight through. Six cards on the AI tab rendered their name underneath their own status pill, measured by testing the title text node's box against the status cluster's. The intended ellipsis never fired either: it is painted by whichever block's own line overflows, and that is the anonymous box around the bare title text node, which does not carry `overflow`. The gutter holds by wrapping now, and the description opts back out of it with a negative margin since the pill only ever covers the first line. Scriberr's `homepage.group=AI Systems` named a group absent from `layout:`, so it had no `tab:` and rendered on all four tabs — the same defect as UltraSeedbox in 2026-08-18, arriving this time from a container label. Relabelled to `AI - Audio Tools`, where the other ASR seats already live. The `homepage.group=AI Systems` sample in the repo-root CLAUDE.md was the source of the name and now carries the constraint, plus the fact that a labelled container is discovered from any of the five configured engines and must not also be listed in services.yaml. Also: descriptions clamped to three lines so a five-line outlier stops dragging its row 50px taller than its neighbours; icon ramp overridden off slate-400 -> slate-700, which was sinking the bottom half of every glyph into the card fill; bookmark groups and Jellyfin's trailing stream rows brought into the card and eyebrow vocabulary; group gap 10px -> 22px now that width no longer separates them. The icon override sits on `html[class]`, not `:root`: Homepage sets the same variables on `.theme-slate`, which is on the <html> element, and a class beats `:root` on the same element. Verified with Playwright against the live board — per-group card width, card height spread, and a geometric title-vs-status collision check, before and after. Before/after captures: http://10.100.10.50:8090/b/homepage-relayout/ |
||
|
|
32349b7653 |
memory: snapshot — Anaheim tunnels on AES-128, ana-gw admin closed, Scriberr live, ESH DNS fixed
Rewrites the in-flight section, which had gone stale in one place (speaches is stopped, not live) and did not reflect a session in which everything opened was also closed. Three detail files carry the bulk: the FortiGate's public surface reduced to nothing including the ACME listener, Scriberr's deployment and the three upstream defects it required working around, and the ESH DNS repair that also made the IPv6 naming scheme real on three hosts. The tunnel entry in the decisions log described a cipher change as proposed and pending; it has since been attempted, found impossible in the form intended, and resolved differently, so the line now points at the outcome rather than the intention. New decisions cover the cipher adoption and the finding that the per-flow ceiling belongs to the UniFi gateway's software cipher rather than the firewall, the administrative closure, the transcription deployment, the DNS repair, the retirement of an ASR service whose only consumer was abandoned, and a database VM that until now had no fleet identity and no vaulted credential at all. Three approaches are recorded as abandoned: an AEAD cipher the far end cannot express, an upstream DNS setting the gateway accepts and ignores, and a scheme to claim unused delegated prefixes that founders not on the prefixes but on having to rebuild a firewall policy to use them. |
||
|
|
d419b11d43 |
docs(ipv6): close the NH3 multi-prefix question by operator ruling
The gateway has no IP-passthrough mode, confirmed by the operator with admin access to it, so the inexpensive path of letting the UDM take the delegation whole and carve it natively is unavailable at this site. What remains is a separate DHCPv6 client presenting several identities, which needs recabling to reach the gateway, splits routing so that one device handles v4 and another v6, and above all moves IPv6 off the UDM's zone firewall, leaving the entire policy to be rebuilt elsewhere before any host could safely hold a globally routable address. The operator has declined it and the LANs stay without v6. The index entry also still asserted the superseded single-delegation conclusion as fact; it now carries the corrected reading alongside the ruling, so the finding and the decision not to act on it arrive together rather than the first inviting a retry of the second. |
||
|
|
bd209951ac |
docs(ipv6): correct the NH3 delegation finding — eight /64s, not one
The note concluded AT&T delegates a single /64 and that reading the address pattern as a /60 was a mistake. A later finding in the same session overturned that by reading the BGW's own statistics page, and that correction was never written down; it survived only in the transcript and surfaced again while assessing whether more prefixes could be claimed. The gateway holds the /60 and rations it, keeping half for itself and delegating the top eight /64s one at a time. Both observations agree: a prefix-id only carves within a delegation already held, so the earlier test could not have moved a lone /64 regardless. The limit is that UniFi solicits once, which makes the requester the ceiling rather than the carrier. |
||
|
|
d127e29fac |
docs(ipv6): retire the next-candidates line now that all three are done
Replaces it with why the remaining segments have no eligible hosts: two are appliance-only and three have no IPv6 enabled yet, pending the firewall-policy pass that SLAAC on a client segment would require. |
||
|
|
4e83395ddf |
docs(ipv6): all three esh-server hosts now carry the segment name
esh-pve-nas and esh-vm-db join esh-docker-vm on 4411:b105, at :50:55 and :50:60 respectively, each applied by the same prefix-deriving if-up.d hook so the last two groups read straight off the IPv4 address. Two obstacles are recorded because both will recur. The Proxmox node had link-local only despite every relevant sysctl appearing correct, because its bridge carries per-interface forwarding and the kernel ignores router advertisements on a forwarding interface unless accept_ra is explicitly two rather than one. The fix takes the advertised prefix while declining the default route, so the hypervisor gains an address without any change to how it routes; this was verified after applying, with the v6 default route count still at zero. The database VM refuses key authentication for the privileged accounts and its unprivileged login cannot escalate without a password, so the hook went in through the QEMU guest agent from the hypervisor, which executes as root inside the guest. The document notes the base64 indirection needed to get a multi-line script through intact. |
||
|
|
ffb7fba346 |
docs: give the ESH IPv6 naming scheme a home, and make it real on one host
The scheme has existed since August as a single line of persistent memory, which a snapshot then deleted. It is a naming convention rather than temporal state, so it now lives in docs/pfi as a proper document, and the memory entry is reduced to a pointer at it. The document carries the full table, the address structure, the reasoning about which slots can and cannot hold a name, and the recipe for applying one to a host. It also corrects the conclusion the original note ended on. That note held that these names could never appear on the wire, which is true of everything UniFi is able to assign but not of what a host can assign to itself, and the distinction is the whole difference between a joke and an address. AdGuard on esh-docker-vm now holds the esh-server name, at 2607:73c0:402:1d02:4411:b105:50:45, where the segment identity and the IPv4 address are both legible. It is applied by an if-up.d hook that derives the prefix at runtime rather than hardcoding it, backgrounds itself with a retry so it cannot stall interface bring-up, and adds nothing to the existing interface configuration. This is load-bearing rather than decorative. The gateway advertises an IPv6 resolver to clients, macOS prefers it over the IPv4 one, and it previously pointed at an address derived from that host's MAC. |
||
|
|
41091eef8f |
docs: restore the ESH IPv6 naming scheme, dropped by a snapshot
The six ESH LAN hexspeak names were recorded in |
||
|
|
6217d3993e |
fix(scriberr): force uv to copy rather than reflink when building envs
Scriberr builds each model backend's Python environment with uv at container start. uv's default link mode reflinks or hardlinks out of its cache, which fails on this overlayfs over ZFS combination and reports it as a failure to clone a metadata file with errno 11, resource temporarily unavailable. The wording points nowhere near the cause. The damage was partial and therefore easy to miss: WhisperX and PyAnnote came up fine and the application served normally, while the Parakeet and Sortformer backends were quietly absent. Setting UV_LINK_MODE to copy trades a little disk and build time for environments that actually materialise, and the occurrence count for that error is zero on the following start. The knob is exposed through the environment so it can be moved back to the default if a future host does support reflinks. |
||
|
|
efddb4e511 |
feat(scriberr): stand up transcription on ana-ml2, pinned to GPU1
Scriberr transcribes audio and video locally with WhisperX and speaker diarization, and it lands on ana-ml2 rather than ana-docker because the work is GPU-shaped: ana-docker offers eight cores already shared with fifty containers and thirty-seven gigabytes of disk, against ninety-six cores, terabytes on /tank and idle capacity on GPU1. The reservation names device 1 explicitly, since GPU0 is fully committed to the gen seat, and the container is confirmed to see that card alone. The image is built from source, which is not a preference. These are Blackwell cards at sm_120; the published CUDA image covers Pascal through Ada only, and the blackwell image the upstream README documents has never been published at all. The path upstream actually ships for sm_120 is Dockerfile.cuda.12.9, carrying CUDA 12.9 and cu128 torch, so that is what gets built. The compose header says so, because the obvious cleanup is to swap in the published image and that would silently drop the deployment to CPU. Two configuration details are load-bearing and documented where someone would go to change them. The application runs as uid 10001 rather than the usual 1000: that Dockerfile moves its user aside for Ubuntu 24.04's own uid-1000 account and chowns /app accordingly, while the entrypoint's remapping covers only the data directories, so at 1000 the process cannot open its database and restarts forever behind a SQLite error that reads as though the machine were out of memory. Secure cookies stay off while the service is reached over plain HTTP, or sessions are dropped by the browser and login appears to loop for no visible reason. Storage is bind-mounted onto /tank because model weights run to several gigabytes and the root pool on that host is nearly full. Also adds the scriberr service alias to internal DNS, following the existing alias convention so consumers name the service rather than the box. |
||
|
|
22ae9cd480 |
ops(ana-gw): disable ACME and retract the all-port VIP claim
The ACME client is unbound from wan1, which removes the HTTP-01 challenge listener that had been holding port 80 open irrespective of allowaccess. An external sweep of fifty-five ports against the WAN address now finds nothing open at all, while the internal interface still serves the GUI and SSH and retains its certificate, which remains valid until late October and simply stops renewing. The previous note's claim that four virtual IPs were unrestricted all-port static NATs is withdrawn. A FortiOS virtual IP can be scoped either by an explicit forwarded port or by a service binding on the object, and the earlier parse inspected only the former. All four use the latter, and the custom services behind them are narrow. None of the fourteen is unrestricted. Ground truth from outside is recorded in place of further config reading, listing what each public address actually exposes. Three configured mappings answer nothing at all and are noted as tidy-up candidates for the migration rather than as exposure. |
||
|
|
f39b66d2e1 |
ops(ana-gw): port 80 on the WAN address is the FortiOS ACME listener
The previous note attributed the open port to an ISP transparent proxy. That was wrong. It terminates on the FortiGate: system acme is bound to wan1, and FortiOS opens port 80 there to answer HTTP-01 challenges regardless of what allowaccess permits, which is why the port stays open with the interface set to ping only. Every non-challenge request returns a fixed 403 whose body reads ACME Access Only. No DNAT is involved; of the fourteen virtual IPs only two land on that address, neither on port 80. The wrong conclusion came from a sniffer filtered on dst host, which matches inbound packets alone and so excluded the replies being looked for. Filtered bidirectionally the box is plainly seen emitting SYN-ACK on port 80. The note records the rule. Two consequences follow. The earlier warning that certificate renewal would fail without http in allowaccess is retracted, since FortiOS opens the challenge port itself. And the listener is not an administrative surface, though its value is now marginal with WAN administration closed, so the note records how to remove it and leaves that decision open. Also captured: four virtual IPs are all-port static NAT and map every port of their external address. |
||
|
|
7bc9754e40 |
ops: adopt AES-128 on both Anaheim tunnels and close public admin
Both tunnels now negotiate AES-128 for ESP, applied make-before-break so neither dropped waiting on its far end: the FortiGate was widened to accept the new cipher alongside the old one first, then each UniFi gateway was flipped. Single-stream throughput moves from 245 to 270 on the NH3 tunnel and from 268 to 304 on the ESH tunnel. Both network objects were diffed against pre-change snapshots and the only field that moved on either is the ESP cipher. The proposal lists are left accepting AES-256 as well. The peers offer only AES-128 so the extra entries are inert, and retaining them means a gateway reverting cannot strand a tunnel. With that up, the WAN administrative surfaces are closed. The interface is back to permitting only ping, and the infra-ops account is again restricted to RFC1918 space. Ports 443 and 22 were confirmed closed from two separate sites and management over the tunnel still works. The close was issued over the tunnel rather than over the WAN, since withdrawing SSH from the interface while connected through it would sever the session mid-command. The box now has no out-of-band path, which the memory records explicitly. Also captured: the two UniFi vault items have different shapes, one a bare key and one a documentation note requiring extraction, which produces an opaque nginx rejection if missed, and the ESH key's first confirmed write. |
||
|
|
6edebe4864 |
ops: forwarding through a downstream WireGuard terminator is free
FortiOS has no WireGuard, so any WireGuard site-to-site has to terminate behind the edge, and ana-wg already fills that role. The earlier 767 Mbit/s figure was taken with traffic terminating on that box, which left the forwarding case unmeasured and overstated what a real deployment would see. Measured properly, transit from NH3 through the tunnel and onward to ana-docker returns 763.8 Mbit/s on one stream and 790.4 across eight, so the forwarding hop costs nothing and the full threefold gain over IPsec survives. The container uses around a fifth of four cores at that rate, against the UniFi gateway spending a third of its own four to move a third as much. The note records what still needs deciding rather than presenting this as ready to build: Anaheim hosts have to route to the terminator directly or pay a hairpin through the edge, which was not measured; the terminator introduces a failure mode the edge did not have, with a fallback route as the mitigation; and the NH3 end needs its own terminator, where a Linux host matches the measured figure while the existing UniFi WireGuard server would likely land lower. The test tunnel, its peer and the temporary route were all removed. |
||
|
|
062215e81a |
ops: the tunnel's per-flow limit is the UDM's software AES-CBC
Varying only the cipher settles what inference could not. AES-128 has ten rounds against AES-256's fourteen, so a software-bound path must speed up when the cipher gets cheaper while a hardware-offloaded one will not move. Run as A/B/A on a single stream, AES-256 returns 232, 245 and 243 Mbit/s and AES-128 returns 282 and 275, a gain of about a fifth at identical CPU. The FortiGate's offload engine is therefore not the constraint, and the operator's reading of the UniFi side as software crypto without acceleration is correct. Two earlier claims of mine were wrong and are corrected in place. The observation that the UDM sat at seven percent CPU was a sampling artifact, since UniFi refreshes that statistic on the device report interval and the windows used were four seconds; a sustained run reads thirty-five percent, around one and a half of four cores. The per-session offload hypothesis is refuted rather than merely unproven, because it predicts no response to a cipher change. The remaining figures follow from this. One stream is one core, eight streams engage about three, and aggregate stops responding to cipher choice once several cores are working because the path bounds it. CBC chaining is what makes this expensive, as it serialises blocks and prevents the AES instructions from pipelining, which is also why the same gateway manages far more over WireGuard. The cipher was restored and the network object verified unchanged against its pre-test snapshot. |
||
|
|
8ecffa1fab |
ops: WireGuard over the same path carries one stream at 767 Mbit/s
The operator asked for a WireGuard test between ana-wg and NH3 across the public internet, which is the arrangement that separates the path from the crypto since neither gateway performs encryption in it. A single stream returns 767 Mbit/s against 245 for IPsec over the same wire, and eight streams return 763, so one flow already saturates the path and there is no per-flow penalty whatsoever. That settles several things at once. The limit is not the circuit, the NH3 uplink, the ISP or the physical path, all of which carry 767 on one flow. Ranking the implementations, Linux WireGuard shows no per-flow penalty, UniFi's WireGuard shows roughly 1.4x, and IPsec on this pair shows 2.8x. Latency under a single bulk stream tells the same story, rising to 12.7 ms on WireGuard against 102 ms on IPsec. This overturns the earlier conclusion that changing transport was not worth pursuing, which compared eight-stream figures and so understated the gap for single-stream work by a factor of three. A WireGuard site-to-site terminated on ana-wg now looks worth considering, and the note records the open questions around failover and policy. Attribution between the FortiGate and the UniFi gateway remains unresolved, since both perform IPsec in the slow measurements, but the remedy does not depend on which one is responsible. The test tunnel was removed, ana-wg is back to its original three peers and the generated keys were shredded. |
||
|
|
d42e9d8712 |
ops(ana-gw): establish the per-flow cap is IPsec-specific, not capacity
The operator asked whether the 80F is simply out of capacity. It is not. Routing a single flow between two Anaheim VLANs through the same box, with no tunnel involved, sustains 940 Mbit/s, which saturates the link, and eight flows over that path return the same figure. There is therefore no per-session ceiling in the plain forwarding path and the roughly 250 Mbit/s per-flow limit belongs to the IPsec datapath alone. Measuring the second tunnel with the same probe supports this. The ESH side, terminating on different gateway hardware at nearly half the round-trip time, returns 268 Mbit/s on one stream against NH3's 245, and 715 against 692 across eight. A window-limited path would have been substantially faster at the shorter round-trip, so the agreement between two dissimilar far ends is further evidence of a rate cap. This also corrects the previous note, which named the FortiGate on reasoning that could not separate the box from the protocol, since every slow path was both. That separation is still not established: both tunnels terminate on UniFi gateways running common firmware, so the cap could belong to either side. The note records the tunnel topology that would decide it and flags the bearing on the pending replacement. |
||
|
|
cf0cb2cbb3 |
ops(ana-gw): pin down what limits a single stream across the tunnel
The earlier note described the constraint as per-flow serialisation without evidence. Pinning SO_SNDBUF and sweeping it shows what it actually is: throughput holds flat between 224 and 247 Mbit/s across a thirteenfold range of in-flight data while round-trip time scales with the buffer instead, from 7.8 ms up to 107 ms. Retransmissions are absent throughout. That is a fixed service rate with a standing queue ahead of it, so socket buffer, window scaling and congestion control are not the lever and should not be pursued. The same measurements surface something with wider consequences than throughput: a single bulk stream lifts tunnel latency from 6.9 ms to 102 ms average, 136 ms peak, with no loss. Interactive traffic sharing the Anaheim link degrades sharply whenever anything moves bulk data, and parallelising transfers makes that worse rather than better. Localisation rests on the FortiGate appearing in both slow paths and in neither fast one, with aggregate throughput over the same security association reaching 692 Mbit/s. Per-session NPU offload fits the shape but was not confirmed on the device; the note records the test that would settle it and flags the bearing this has on the pending FortiGate replacement. |
||
|
|
e41d19f1cb |
ops(ana-gw): close out the Anaheim tunnel cipher question
The AES-GCM cutover was authorised and attempted, NH3 side first. It cannot be completed: UniFi's manual site-to-site IPsec implements no AEAD cipher. Eight GCM spellings were rejected with api.err.InvalidPayload while an otherwise identical body carrying aes256 returned rc:ok, which isolates the enum rather than the request as the cause. The accepted set is aes128/aes192/aes256/3des. Both Anaheim tunnels terminate on UniFi gateways, so this blocks the ESH tunnel on the same grounds. Measuring while testing also retires the premise. NH3's uplink is a 1 Gbps link, so Anaheim's 2 Gbps circuit was never the relevant ceiling, and the tunnel sustains 692 Mbit/s across 8 streams rather than the ~550 previously recorded from a 4-stream run. Compared with WireGuard over the same gateway and uplink, the deficit narrows from 2.3x at one stream to 15% at eight, so moving this link onto a different transport is not worth doing. The constraint is per-stream, around 245 Mbit/s, with both endpoints idle under load. Parallelising bulk transfers remains the mitigation and is worth 2.8x at no cost; NFS nconnect is the equivalent lever for single-stream mounts such as /mnt/smithy on ana-ml2. FortiGate phase2 for pfi-ana-nh3 keeps the widened proposal list, which is inert while the peer offers only CBC and avoids a further renegotiation. The UDM network object was diffed field-by-field against its pre-change snapshot and is unchanged. |
||
|
|
5af362e9d0 |
ops(ana-gw): restore WAN admin access ahead of the FortiGate cutover
Re-open the ana-gw admin GUI on wan1 so the Anaheim edge can be managed remotely if the cutover goes wrong, reversing part of the 2026-08-12 lockdown. Two config changes, nothing else (verified by diffing pre/post `show full-configuration`): - wan1 `allowaccess ping https` — https only; http, ssh, and fgfm stay off, and wan2 is untouched. - `infra-ops` trusthost widened to all routable IPv4; the `admin` account stays locked to 10.0.0.0/8 so the guessable username remains unreachable from the internet. Verified end-to-end from two sites: a real `/logincheck` POST returns AUTH OK over the public path, on a browser-trusted Let's Encrypt cert for ana-fw.phasefinal.com valid through 2026-10-27. Two FortiOS behaviours worth recording, both of which cost time here: a trusthost whose base address is 0.0.0.0 is silently treated as unset (so there is no writable "any" — only decomposed ranges), and trusthost is enforced before the TCP handshake, so a blocked source sees a filtered port rather than a refused login. Follow-ons captured in memory, not actioned: ACME renewal for the admin cert needs port 80 on wan1 (next attempt ~2026-09-27), and a ~5 SYN/s source in 179.51.184.0/21 now draws SYN-ACKs at no measurable CPU cost. |
||
|
|
b6340519bc |
memory: snapshot — Anaheim tunnel at 25% of circuit; selene retired; hrafn CI fixed
Session captured for a context reset. Six new detail entries. THE OPEN ITEM: Anaheim's IPsec tunnel delivers ~550 Mbit/s aggregate against a circuit measured at 2,153 Mbit/s. Not WireGuard (it is IPsec on ana-gw), not CPU (idle), not crypto exhaustion (NPU-offloaded), not the fibre. Both tunnels negotiate aes256-sha1; AES-GCM proposed. Operator signalled authorization; execution pending, untracked by operator choice. Also recorded: selene retired after losing a head-to-head on its own job with chat-judge moved to gen and the model name left to 404; the 7-alias collision on the gen seat that makes cross-alias corroboration an echo; hrafn adopted and its CI found to have been reporting green while deploying nothing for its whole life; all three Worldtree instances de-armed from a 69-day-stale :latest and the Matrix homeserver re-plumbed to personal; every secret-bearing .env on ana-docker tightened to 0600; the pfi org closing the repo-creation half of the credential-migration directive; booth kept-board deletion and link pruning. Two entries under Tried and abandoned: the CI checkout assertion that broke the pipeline twice and was removed, and my proposal to alias a retired model name at a different model, which the operator correctly overruled. Index 271 -> 282 lines, under the 300 cap, so no archival fired. |
||
|
|
0ad332bb4a |
feat(booth): per-row link removal + render the link board as real UI
The standing link board is the one MULTI-WRITER booth -- every agent session appends operator-facing URLs to it. "Delete the folder" was the only granularity available, so removing one dead link meant hand-editing markdown. It is 32 rows and only grows. booth links row number, entry id, raw row booth unlink 3 by row number booth unlink 8b40e0a5 by entry id (what the UI's x posts) POST /b/<name>/unlink form field `entry` = content id ROWS ARE ADDRESSED BY CONTENT ID, NEVER BY POSITION. The board is append-only and multi-writer: another session can post between listing it and clicking x, and an index would then delete a neighbour. An id either matches the row you saw or matches nothing. A row number typed at the CLI is resolved to its id BEFORE anything is deleted. Appends and prunes now take the same flock on .links.lock, so a post cannot be lost inside a prune's read-modify-write. UI: a booth carrying links.md renders as rows -- description, URL, provenance, copy button, per-row x -- instead of a markdown blob. links.md is filtered out of the gallery so it does not appear twice; the header counts LINKS not files; the empty-state and the one-click "Wipe now" both stand down for a board (same rule as the kept lane: nothing durable is one click from gone). booth/links.py extracted, STDLIB ONLY. The CLI needs this logic and must not require the service venv -- importing app.py drags in FastAPI, so deleting a line from a text file would have needed a web framework installed. THREE BUGS FOUND BY TESTING, all in the shell wrapper while the module was correct throughout -- module-only tests would have caught none of them: - `[ "$n" -eq 0 ] && echo ...` as the LAST statement made `booth links` exit 1 whenever the board had rows. `unlink`'s index lookup calls it inside $( ) under `set -e`, so a successful listing killed the caller and the removal silently did nothing while reporting success. - ids are 8 hex chars and roughly one in forty is ALL DIGITS; those were read as row numbers, resolved to nothing, and removed nothing. Now disambiguated by the id's actual shape, not by "is it numeric". - filtering links.md out of the gallery left `items` empty, so a full board rendered "This booth is empty" and an empty <div class="gallery"> under 32 visible rows. 87 tests (was 76): parser tolerance of hand-written prose, content-id stability across concurrent appends, removal precision, UI branch behaviour for board/normal/empty booths, and subprocess CLI tests pinning the two shell bugs. Deployed to nh3-dev and verified against the live 32-row board read-only; board file byte-identical afterwards. |
||
|
|
4be880f36c |
feat(booth): kept boards can be deleted from the UI; document the TTL-reset trap
Kept boards had no delete path in the UI at all. The kept lane deliberately omits the wipe control -- a one-click wipe next to the durable stuff is a footgun -- but "deliberate" had been implemented as "impossible": the only routes out were ssh or a hand-written API call. Now it is two deliberate acts. A `release` control on kept cards drops the sentinel, the board moves to the ephemeral lane, and the existing x wipes it from there. Release is reversible -- POST /b/<name>/keep pins it again. POST /b/<name>/unkeep release the pin POST /b/<name>/keep pin it (round-trip, so release is not a one-way door) FOUND WHILE TESTING, and it invalidates the previously-documented workaround: removing the sentinel BUMPS the booth directory's mtime, and booth age is the newest mtime in the tree -- so a released board's clock RESETS from 10,000s to 0s and it survives another full TTL. The old comment said "remove the sentinel first (it rejoins the sweep)", which is true but means the board lives another 24h, not that it gets reaped. Unkeep-and-wait is a delay, not a delete. test_releasing_a_board_RESETS_its_ttl_clock pins that behaviour deliberately so nobody re-derives the workaround. Release is what unlocks the x; the x is what deletes. CLI: `booth rm` already worked on kept boards but said nothing about it. It now announces "(was KEPT -- durable board)" so wiping something durable can never look identical to wiping run output. Not a block -- a CLI user naming a booth is being explicit. 5 new tests (67 pass). Verified live on nh3-dev: release renders on all four kept boards, the ephemeral lane keeps its x, and the links board is untouched with its sentinel intact. |
||
|
|
b8a535507a |
feat(judge-bench): keep the judge harness; warn about the 7-way alias collision
Operator: keep the benchmark. It has a named second use (brokkr-smithy-dev wants gen vs a trained reward model once their tournament converges) and a demonstrated first one -- it caught a seat that had been coin-flip-grade for five weeks with nobody measuring it. Harness promoted from scratch to tools/judge-bench/: - paths de-hardcoded; runs from its own directory - proper CLI: --models (REQUIRED), --repeats, --limit, --gateway. Required on purpose: a stale default would silently benchmark a retired seat, and the original default (selene-1-mini-8b) now 400s. - README states the limitation rather than burying it: 24 items of the author's own design, a screen and not a verdict. This harness scored the same pair 83 vs 96 while brokkr's corpus ranking task scored it 47 (chance) vs 94. Both honest; absolute scoring on designed items is an easier task than ranking real text. - records brokkr's technique, which is better than anything here: a control constructed so the correct answer is DEFINITIONAL rather than judged cannot inherit the designer's error (item vs itself, response vs its own truncation, text vs its own clauses permuted). Add those before adding more judged items. Gateway: comment-only warning at the head of model_list. SEVEN aliases now resolve to the same weights (chat-judge, classifier, gen, image-judge, qwen-image-bench, summarizer, summarizer-large -> qwen3.8-27b-uncensored). That is intended under ADR-0012, but it has a sharp edge brokkr flagged: cross-checking a result against another alias measures NOTHING when they are the same model -- agreement is an echo, not corroboration. The note names the other current collisions (glm-5.2 x4, TTS x4, reranker x2), gives the /model/info one-liner to check, and records that probes should resolve alias -> backing at run start AND end because the response `model` field returns the alias, so a swap is otherwise invisible. Verified: config still parses, diff is comment-only, canonical re-synced. |
||
|
|
ca3c984f93 |
feat(selene): retire the seat; chat-judge -> gen, selene-1-mini-8b 404s by design
Benchmarked selene against gen on selene's own job: 24 designed judge items with checkable ground truth, pairwise + absolute modes, 3 repeats, on BOTH a neutral JSON prompt and Selene's native Atla template. 288 calls, all free local. neutral JSON selene 20/24 (83%) gen 23/24 (96%) native Atla selene 21/24 (88%) gen 22/24 (92%) gen won on both templates and selene's BEST sat below gen's WORST. Selene was given its own fine-tuned template as a fairness check; it gained one point, not the three it needed. Decisive defect: selene cannot emit "tie" -- 0/2 on both templates, forcing a winner on every equivalent pair. For eval work that is the case that matters most. gen returned tie correctly on the JSON template. Selene also compressed the 1-5 scale (clustered at 2s and 4s) where gen used it fully. Selene's only win was ~3x latency, unexercised at ~60 calls/day with zero queueing. TWO NAMES, TWO DIFFERENT TREATMENTS, deliberately: - chat-judge -> repointed to gen. It is a ROLE alias and ADR-0012 says consumers bind the capability, not a concrete model. Sampler profile copied from image-judge (temp 0, top_p 1.0, top_k 1, thinking off) so the served config matches the benchmarked condition. - selene-1-mini-8b -> REMOVED. It 404s. It was NOT aliased to gen. A served-name is a contract about what the model IS; answering it with a different model hides a material change behind a stable string. Operator ruling: "never repoint a named model at a different model's endpoint -- that is intentionally misleading." Verified: the gateway now returns HTTP 400 "Invalid model name" for it. Reclaimed 17.2 GiB on ana-ml2 GPU 1 (free 1,818 -> 19,450 MiB) on a card that had under 2 GiB of headroom. gen already runs on GPU 0, so the judge role moved onto an existing seat rather than allocating anything new. Canonical litellm config synced from the host; ana-ml2 README and recommended-model-settings updated. compose.yaml kept for reference, not deployed. |
||
|
|
a896c0a5a9 |
feat(playbooks): sweep world-readable secret-bearing .env files to 0600
Eight stacks on ana-docker kept secret-bearing compose .env files at mode
0644 -- readable by every local account on a box with four interactive
users. Verified as a real exposure, not a theoretical one, by reading one
as `nobody`. Six other stacks already used 0600, so this converges on the
existing house pattern rather than inventing one.
Swept (all verified mode 600, unreadable by nobody, compose config still
rendering as the deploy user, container untouched):
vaultwarden traefik beszel gitea-runner
miniflux news-digest searxng vor
Post-sweep the host has zero secret-bearing .env readable by nobody.
Checked before writing the playbook, and re-checked by the playbook itself
so a bad case cannot be swept in by accident:
- every target is owned by lkraven, the deploy user, so 0600 preserves the
deploy path (asserted by rendering compose config AS lkraven, not root --
checking the mode proves the bits changed, only rendering as the deploy
user proves the next deploy still resolves its variables)
- none is bind-mounted into a container. vaultwarden looked like it was;
that `- .env` is under `env_file:`, not `volumes:`. A genuinely
bind-mounted .env is read by the container UID and 0600 could break it,
so the playbook refuses that case.
Two gotchas recorded in the file:
- elway's variable regex eats any bare identifier in braces, so a docker
--format Go template containing {{end}} or {{println}} fails as an
undefined variable. Grep the raw inspect JSON instead.
- the `ana-docker` ssh alias resolves to lkraven, who needs a sudo
password; run elway against infra-ops@10.250.50.70 for NOPASSWD.
|
||
|
|
9642952a54 |
docs(hrafn-ci): mirror the now-canonical vh/hrafn pipeline; record two failed runs
claude-bot holds write on vh/hrafn as of 2026-08-23, so the canonical copy of the pipeline moved there and infra-ops maintains it directly instead of routing patches through the repo holder. The files here are a verified mirror (byte-identical to live at 22e0eb9d75a6). Live state: run 9922 green, and both new content assertions executed rather than merely existing -- verify 4/5 host tree matches shipped context (a99ce748a0c9...) verify 5/5 image source matches host source (06f209fd0641...) The CI-computed context hash matching on the host is the end-to-end proof that the converge lands what CI ships. Its absence is what let the frozen-source bug survive every green deploy. Also records why the HEAD == GITHUB_SHA assertion was added and then removed: it needed the git binary (run 9920, exit 127), and installing git flipped actions/checkout@v4 from its node implementation to the git binary, which died on a missing CA bundle (run 9921). A nice-to-have assertion changed the checkout code path and broke a working pipeline; it guarded a hypothesis that proved wrong, so it went rather than getting ca-certificates bolted on. |
||
|
|
b38c369313 |
fix(hrafn-ci): staging dir inside the rsync target froze host source silently
Root cause of nevermore-claude's report that v1.0.0 deployed green while the host kept serving 0.1.0. The staging dir was $compose_dir/.stage -- INSIDE the rsync target. So `rsync -a --delete $compose_dir/.stage/ $compose_dir/` deleted .stage from the destination (absent from the source listing) DURING the transfer, destroying the source mid-copy. Reproduced exactly: before: app.py="OLD" leftover.txt .stage/app.py="NEW" after: app.py="OLD" leftover.txt GONE, .stage GONE Deletion worked; the copy silently did not. So the directory looked converged while host source stayed frozen at the first manual rsync, and because the build's COPY inputs never changed, Docker full-cache-hit and every SHA tag aliased one image. The provenance guarantee was false. Nothing caught it because the verify steps asserted the marker, health, and a 200 from /readyz -- all of which pass on a frozen host. None measured content. Fixes: - stage at /tmp/hrafn-deploy-stage, outside the target - CI computes context_sha256 over the shipped file list; the playbook recomputes it on the host post-converge and fails on mismatch - compare the running container's src/**/*.py against the host's, catching a SHA tag naming layers the image does not contain - checkout clean:true + assert HEAD == GITHUB_SHA so a reused runner workspace fails the job rather than shipping a stale tree Declined --no-cache: a cache hit is correct when the context is genuinely unchanged, and the new assertions prove the property directly rather than brute-forcing it. The container-vs-host check compares only *.py -- `pip install .` generates src/hrafn.egg-info/* inside the image and __pycache__ appears at runtime, so a naive `find src -type f` compare false-fails on every healthy deploy. Verified against the live container before shipping (12 host files, 18 in container, 0 content differences). |
||
|
|
bb19a96f39 |
fix(playbooks): compare image IDs, not the created-from tag; make the repin idempotent
Two defects the worldtree-pinned case exposed in the guard written an hour ago. 1. The guard compared the container's .Config.Image STRING against the tag being pinned. That string is only the tag the container was CREATED from, which can differ from what it actually runs: worldtree-pinned was created from `:latest` back when that tag pointed at 446e5807, and `:latest` has since moved to b19afd71d7cc. So the guard refused an instance whose pinning was correct and necessary. Now it resolves the target tag to an image ID and compares that against the running image ID -- asserting the thing actually cared about, that this tag names the bytes now running. It also fails closed when no such local tag exists. 2. The sed step reported CHANGED unconditionally, so a re-run on an already-pinned instance claimed work it had not done. Gated behind a `when:` that skips when the line is already correct; a second run on demo now reports "2 ok, 0 changed, 2 skipped / overall: OK". Applied to worldtree-pinned under worldtree-dev authorization. That instance needed a `docker tag` first -- its image was DANGLING (no repo tags, kept alive only by the running container), so the fleet's frozen reference was one `docker rm` from garbage collection. Tagged as :446e5807bf43, then pinned. All three instances now render a SHA with no floating tag anywhere: worldtree -> :ae88a057c0ed worldtree-personal -> :f63529168c13 worldtree-pinned -> :446e5807bf43 Nothing restarted -- pinned still Up 3 months, its start time unchanged. |
||
|
|
064181a8fb |
feat(playbooks): re-pin Worldtree WORLDTREE_IMAGE off the floating :latest tag
Both corviduo-dev Worldtree instances carried `WORLDTREE_IMAGE=.../worldtree:latest` in their .env while running SHA-tagged images built the same day. The local :latest tag pointed at b19afd71d7cc, built 2026-06-14 -- 69 days stale. Any `docker compose up` on either instance, by anyone, silently downgraded that service by 69 days: the same footgun behind the 2026-06-15 outage. Applied under worldtree-dev authorization (Worldtree #410): demo -> gitea.phasefinal.com/vh/worldtree:ae88a057c0ed personal -> gitea.phasefinal.com/vh/worldtree:f63529168c13 Both runs verified compose config resolves every service to the pinned SHA with no :latest remaining, and that no container restarted. The edit is inert by design -- it changes what the NEXT recreate pulls. The playbook guards against pinning a SHA that is not the one actually running, which would re-arm the same hazard with a different image. Also records the worldtree-pinned case, deliberately NOT changed here: that instance runs a DANGLING image (sha256:446e5807, no repo tags), kept alive only by the running container. It has no tag to pin to, so it needs `docker tag` first -- and an untagged image referenced only by a container is one `docker rm` from garbage collection, which for the fleet's frozen reference instance is worth fixing on its own merits. Out of the authorized scope (demo + personal); raised with worldtree-dev. Stopgap: the durable fix is the deploy workflow stamping the deployed SHA into .env at each deploy, queued repo-side with worldtree-dev. |
||
|
|
11b9d1891e |
fix(hrafn-ci): make the deploy converge instead of accrete
The first CI run shipped clean but revealed a design gap in the playbook: unpacking the context tarball in place overwrites tracked files and never removes anything. Leftovers from the pre-CI hand-rsync (tests/, docs/, ROADMAP.md, persistent-memory.md, CLAUDE.md, LICENSE) survived the deploy and had to be cleaned off ana-docker by hand. That is the same failure class that produced the mess in the first place: a deploy that only ever adds cannot return the host to a known state. - unpack to a staging dir, then rsync --delete onto the compose dir - protect host-owned .env and .deployed by name - add .env.example to the context tarball so converge does not delete it - record in the workflow that the tar list is now AUTHORITATIVE: anything omitted is removed from the host on the next deploy Re-validated with `elway --dry-run` (9 steps, 3 verify, parses clean). Not yet in vh/hrafn -- infra-ops has no write access there, so this is offered to the repo holder rather than pushed. |
||
|
|
b001d0cb2e |
feat(hrafn): author the CI deploy that replaces the hand-rsync path
hrafn was handed to infra-ops for uptime ownership with no CI deploy and no commit provenance -- the image was always local/hrafn:v1 and the whole working tree lived in the compose directory. These two files fix both. They are authored here because infra-ops owns hrafn's uptime, but they belong in vh/hrafn; claude-bot is not a collaborator there, so they are handed to the repo holder rather than committed directly. - playbooks-deploy.yaml -> vh/hrafn playbooks/deploy.yaml - gitea-workflows-deploy.yaml -> vh/hrafn .gitea/workflows/deploy.yaml Design calls recorded in the README: the build context travels as one tarball rather than per-file upload steps (nevermore's pattern fails open when a new source file has no matching step), and the playbook refuses to deploy unless .env exists at 0600 -- a guard prompted by it arriving 0644 with a live bearer token in it. Validated with `elway --dry-run`, which caught a real interpolation bug during authoring. No new Actions secrets needed. |
||
|
|
b6924de728 |
feat(hrafn): register the browser-fetch service and tighten its env perms
nevermore-claude handed hrafn to infra-ops for uptime ownership. Intake: verified health independently, brought it into the inventory, and fixed one security defect found during the check. - canonicalize stacks/hrafn/ (compose.yaml + .env.example + README) - list hrafn in the ana-docker running-stacks table - README records the load-bearing bits: shm_size 1gb is required for Chromium, playwright and the base image are version-coupled, SSRF denies private targets by default, and restart:unless-stopped does NOT act on healthcheck failure Security fix applied on the host, not in this tree: /opt/docker/compose/ hrafn/.env was 0644 with a live 57-char bearer token in it, readable by every local account (verified by reading it as `nobody`). Tightened to 0600; owner unchanged so the deploy path still works, confirmed via `docker compose config` as lkraven. Container untouched and still healthy. Known gaps recorded rather than silently accepted: no CI deploy, and the image builds from an rsync'd working tree living in the compose directory, so the running image has no commit provenance. |
||
|
|
7bf17dd39e |
fix(filezilla): set restart: unless-stopped and canonicalize the stack
The filezilla container on vm-esh-nas shipped with no restart policy. It was SIGTERMed during the 2026-08-18 host shutdown and never came back, staying down four days while dockge/dozzle-agent/beszel-agent all recovered on their own. - add `restart: unless-stopped` (applied on the host, container recreated and verified serving 200 on :5800 with all four /NAS mounts visible) - promote the previously un-canonicalized compose into stacks/filezilla/ so the policy is version-controlled rather than host-only - record the missing-restart-policy failure mode in the host README |
||
|
|
837fa362fc |
memory: snapshot — sec rebuilt on DFlash2 + newer vLLM; quant tokenizer defect fixed fleet-wide
Rewrites the in-flight section (140 lines to 18) now that the AI-seat work has settled, promoting the durable calls into the dated decision log where they will not decay: DFlash2 measured and promoted to sec's compose stack, the calibration-truncation defect fixed at source in the quant pipeline, sec retuned after a runtime OOM, the Max-Q spread disproved by telemetry, ESH dual-stack with the Cityside static proven unprovisioned, and speaches live with the weak-gate finding. Carries forward the two things a fresh session must not get wrong: the sec degeneration question is confounded because engine and drafter changed together, and neither the 2k nor the 10k observation is evidence. Auto-archived one entry (Recent decisions: 1) to archival-memory.md. Eight further candidates were held back by the guards -- open deferred work or referenced by in-flight state. |
||
|
|
6e82899ba7 |
fix(mog-sec): retune to 0.52/420K after a runtime OOM at 0.55/480K
The 0.55 configuration booted cleanly, passed every startup check, and then killed the engine on an ordinary request of roughly 20k tokens: torch.OutOfMemoryError allocating 614 MiB in the GDN causal_conv1d path with 443 MiB free. The container self-recovered via restart: unless-stopped. Context length was not the cause. gpu-memory-utilization is not a hard reservation. Weights and KV are reserved up front, but activation memory is allocated on demand and the profile that sizes it uses dummy data at max_num_batched_tokens. The profile predicted 3.62 GiB; the process sat about 1 GiB over its entire budget even at idle. With six vLLM containers sharing GPU1 there was no slack left to grow into, and a modest allocation lost the race. 0.52 leaves roughly 7.7 GiB free against 4.6 GiB at 0.55. Records a measurement that governs how max-model-len can be chosen at all: the KV pool varies between boots. Two consecutive boots at 0.52 produced 425,663 and 453,600 tokens, a 6.6 percent swing, because the pool is sized from free memory at startup and that depends on what the other tenants hold at that instant. 420,000 sits under the lower observation so the seat starts in both conditions. Sizing to the higher one would boot today and refuse tomorrow, which is why the value was measured across boots rather than estimated from one. Concurrency of about 1.0 to 1.08x means one request at full length. Shorter requests still batch normally, since vLLM schedules by tokens rather than by max-model-len. |
||
|
|
8389470898 |
feat(mog-sec): promote the DFlash2 configuration into the compose stack
Operator approved after real-use testing. The experimental standalone container is retired and stacks/mog-sec is canonical again, with restart: unless-stopped so the configuration survives a reboot. Cutover verified against the container it replaces: KV pool 526,617 tokens at 1.10x concurrency, identical; zero restarts; both gateway aliases serving; DFlash2 confirmed drafting at k=7 with 231 draft tokens over 33 drafts; vision working at 2048x2048. One variable was deliberately dropped rather than carried over. The previous stack hardcoded PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True, the validated container never set it, and the quant playbook records expandable_segments corrupting retained tensors in another context. The compose now defaults it empty via MOG_ALLOC_CONF. Promoting the stack as it stood would have shipped a variable the tested configuration did not have. The speculative config moves into a single MOG_SPEC_CONFIG carrying the whole JSON, because the two shapes are not interchangeable: dflash requires a model pointing at the drafter and MTP must not have one, so a method-plus-tokens template cannot express both. Also parameterised: MOG_DRAFT_MODEL, MOG_MM_PROCESSOR_KWARGS, MOG_MAX_NUM_BATCHED_TOKENS. The mm-processor image cap is now mandatory rather than incidental. The model's own preprocessor declares 4096x4096, which expands to 16384 image tokens and kills startup on builds that enforce the image-token count check. Adds the .env.example this stack never had, carrying the measured rationale for each value and the one-line rollback. |
||
|
|
20ac53052b |
memory: retract the MTP-head degeneration hypothesis; n=1 was never evidence
Operator ruling: the multi-turn degeneration lives in the un-fixed vLLM, not in the weights. The hypothesis that sec's stock-graft MTP head causes it is withdrawn. Two failures produced it. First, a false dichotomy treated as a deduction: having verified gen and sec run an identical engine, I concluded config was eliminated and therefore the weights were responsible. That does not follow. An engine bug present in both seats is not exonerated by the seats being identical; it only means the engine cannot explain a difference between them. It can still explain the failure. Second, and more instructive, the difference being explained may not exist. The premise was a single operator observation made during a session with many concurrent changes. That cannot carry a causal claim, and it became the load-bearing support for a root-cause narrative it could not hold. The same caveat now attaches to the coherent-to-10k observation on the new build: same n, same uncontrolled conditions, opposite direction. The comparison is weak at both ends, so the file no longer presents either sighting as a result. What survives as measured fact is unchanged and still recorded: sec's MTP head is byte-identical to the uncensored base across all 15 tensors, gen's was abliterated in-band, and acceptance differs slightly. None of that is shown to cause degeneration. Adds the generalisable lesson: an observation made while many things are changing cannot support a causal conclusion. It is the inverse of the warning already in the gen-seat compose file, which guards against trusting a negative result from a synthetic probe; this guards against trusting a positive sighting from an uncontrolled session. |
||
|
|
ab3a0ca5bc |
docs(quant-playbook): acceptance is not throughput -- always run the depth control
Measured 2026-08-22 on one target with one instrument: raising MTP num_speculative_tokens from 3 to 7 improved accepted length from 2.753 to 3.041 per forward pass while throughput fell from 114.9 to 74.0 tok/s. Reporting acceptance alone would have recommended a 36% regression. The cause is architectural rather than model-specific. A single-module MTP head has no depth of its own, so vLLM runs it autoregressively and k draft tokens cost k sequential forward passes. Past a shallow depth the drafting cost exceeds what the extra accepted tokens save. Records the comparison rule that follows: match k when comparing two speculative methods, or the measurement is of depth rather than method. A parallel-drafting drafter at k=7 against an autoregressive MTP at k=3 is not a method comparison. In the case that produced this, the depth control showed most of the apparent acceptance advantage was depth, while the throughput advantage was real and came from parallel drafting -- our MTP was better at position 0 and still lost overall. Only the measured, model-agnostic result is recorded here. The DFlash2-specific findings, the hypotheses that remain unproven, and the wrong turns taken along the way live in persistent-memory.d/2026-08-22-dflash2-spec-decode.md with explicit epistemic labels, deliberately kept out of the playbook. |
||
|
|
9f87b7c4e5 |
memory: DFlash2 spec-decode measured; sec running on it (experimental, confounded)
Records the 2026-08-22 session with measured results, hypotheses, and wrong turns explicitly separated -- the operator held this back while it was in flight specifically so conjecture would not enter the record as fact. MEASURED. DFlash2 works on an abliterated + NVFP4 compressed-tensors target on Blackwell sm_120, which the model card does not claim (it tests stock BF16 on H200). gen 2.753 -> 3.254 accepted tok/forward and 114.9 -> 131.9 tok/s; sec 2.676 -> 3.252 and 110.5 -> 130.0. The drafter is model-agnostic across two different finetunes to 0.06%, but is EAGLE3-style coupled to its target's hidden states, so the weights file is shareable while the 3.85 GB of VRAM is per-seat. The k=7 MTP control is the load-bearing result: raising MTP depth improves acceptance and collapses throughput to 74.0 tok/s, because a single-module head run autoregressively costs one forward pass per draft token. Without that control the obvious recommendation would have been wrong. CONFOUNDED. sec no longer degenerates at 2k, but the engine advanced 259 commits and the drafter changed at the same time. Isolating it means running MTP k=3 on the new build. Also recorded: #51113 is present in both builds and is therefore necessary but insufficient, since sec ran it and still degenerated. HYPOTHESES, labelled as such: that sec's stock-graft MTP head causes the degeneration, and that NVFP4 explains the gap against published acceptance figures. Neither is proven. WRONG TURNS, recorded so they are not re-derived: version strings are not lineage, Docker Hub push timestamps are not source freshness, and the claim that 1M context needs YaRN absent from config is false for the sec quant. Operationally important: sec is serving from a standalone container rather than its compose stack, which is stopped but unmodified. Rollback is two commands and is written down. |
||
|
|
0755ba7d00 |
fix(quant): stop baking the calibration truncation cap into the shipped tokenizer
load_calib tokenizes with tok(..., truncation=True, max_length=seqlen). For a
fast tokenizer that mutates the Rust backend's truncation state in place, and
the subsequent tok.save_pretrained() persisted it, so every mixed-NVFP4 build
shipped a tokenizer.json carrying
"truncation": {"direction": "Right", "max_length": 2048, ...}
against a source whose value is null. Every prompt was clamped at the
calibration length, permanently.
It hid because older transformers does not enforce the text-vs-ids count
check. On a newer one the seat dies at startup with a message that names
images and never mentions tokenizers:
ValueError: Mismatch in `image` token count between text and `input_ids`.
Got ids=[2047] and text=[16384].
The cap also silently limited image resolution well before it killed
anything -- at 2048 the largest servable image is about 1448x1448, since
(edge/patch)^2 / merge^2 image tokens have to fit under it.
Fix saves a pristine tokenizer re-read from the source rather than the
mutated calibration object, and then asserts truncation is null so the
defect fails the build instead of shipping again.
Playbook gains section 3.14 with the symptom, the cause, the audit one-liner
and a table of which builds were affected, plus a fourth mandatory post-step.
The transferable lesson is called out: this is the third case of an artifact
carrying config authored against an older transformers that a newer one
begins enforcing, so an image bump is a config-compatibility event rather
than just a version change.
|
||
|
|
ad21302474 |
memory: ESH internal IPv6 live on two LANs; Cityside v4 static proven unprovisioned
Operator-directed IPv6 enablement on ESH-UDMPM: esh-userland (VLAN 10, SSID PVC) and esh-server flipped from ipv6_interface_type=none to pd against the delegated 2607:73c0:402:1d00::/56. Verified esh-docker-vm egresses over v6 as its own address with no NAT, against v4 which still egresses as the shared CGNAT pool. No disruption through either write; originals backed up. NH3 stays v6-off deliberately -- its single /64 is reserved for network meshing, so nh3-dev having no v6 egress is correct rather than a fault. The v4 static is NOT provisioned, and this is now proven rather than inferred. A full gateway reboot -- verified by observed down-transition and an uptime reset from 140,846s to 183s -- forced a fresh DHCP DISCOVER and Cityside returned the identical address. Recorded alongside it: a DHCP renew could never have surfaced a new address in the first place, since renewal preserves the lease by design. Next step is a carrier ticket, not more testing. The v6 firewall posture was audited and is correct -- default-deny inbound for both IP versions, only four hand-written policies, none WAN-facing. My earlier warning that v6 removes CGNAT's accidental shielding was overstated and is retracted in the record. The audit's limitation is stated: it read rules, it never probed from outside, because no v6 vantage exists. Flat-zone lateral-movement finding parked as id 44 rather than actioned -- operator's call, moot until the mesh and segmentation design settle. |
||
|
|
c7e21879ae |
memory: speaches ASR live on irv-ml1 — and no_speech_prob is a weak gate
Records the deployment plus the finding that outlives it: no_speech_prob alone does not carry a hallucination threshold on this stack. Silence and room tone both hallucinated 'Thank you.' at no_speech_prob under 0.11, where a conventional >0.6 gate passes them through; avg_logprob separates ~6x better. Any future consumer asking for a Whisper hallucination gate should be pointed at the composite, not the single field. Also banks the digest-pin rationale (the VAD flag is an upstream-declared unstable variable name, so a floating tag can move gate semantics silently), the two deployment gotchas, the docker-vs-native GPU index inversion on this host, and Eyra's future diarization VRAM claim on the same A6000. Adds speaches to the irv-ml1 stack table alongside parakeet, with the reason both exist. |
||
|
|
aa5863c9a3 |
feat(speaches): OpenAI-compatible faster-whisper ASR seat on irv-ml1 A6000
Deployed for Eyra (meeting recorder) per the eyra-dev request. Serves
large-v3 (batch tier) + distil-large-v3 (low-latency tier) on :8204,
fp16, both resident, ~5.9 GB VRAM against 20 GB still free.
Sits alongside the existing parakeet stack (:8765) deliberately: parakeet
is a TDT/transducer returning bare {"text": ...} and has no no_speech_prob
concept, so it structurally cannot serve this consumer.
The load-bearing requirement -- segments[].no_speech_prob surviving
response_format=verbose_json -- is VERIFIED on both tiers.
Measured finding worth more than the deployment: no_speech_prob alone is a
WEAK hallucination gate on this stack. Pure silence and pink room tone both
produced the classic Whisper 'Thank you.' hallucination while no_speech_prob
stayed under 0.11 -- a conventional >0.6 threshold passes both through.
avg_logprob separates the same cases ~6x more decisively (-0.11 speech vs
-0.65/-0.72 non-speech) and compression_ratio splits 1.141 vs 0.556.
Consumers should gate on a composite, not no_speech_prob alone. Table in
the README.
VAD pinned OFF at the consumer's request (they VAD-gate upstream on the
capture edge). Consequence stated plainly in the README: with VAD off this
service will transcribe silence into text and is not defending itself.
Image pinned BY DIGEST rather than :latest-cuda, because the VAD-off
setting rides on _UNSTABLE_VAD_FILTER -- a variable upstream explicitly
marks unstable. A floating tag could rename it on any bump, silently
restoring VAD and moving no_speech_prob semantics under a calibrated gate
with no error and no log line.
Two deployment gotchas recorded: PRELOAD_MODELS only loads models already
cached (it does not download -- use POST /v1/models/{id}), and the bind-
mounted cache needs a hub/ subdir or every /v1/models call 500s with
CacheNotFound while /health still returns 200.
|
||
|
|
ff5ce212da |
memory: OWUI credential state — password reset, admin-scoped API key vaulted (operator-ruled)
Two OWUI credential facts worth surviving a context reset:
- The reported lockout was NOT a broken account. The vaulted password returned
200 from POST /api/v1/auths/signin immediately before the reset, so the cause
was client-side. Test the vaulted credential against signin before concluding
an account is broken -- this is the second lockout here traced to an assumed
rather than verified credential.
- The operator-minted API key is ADMIN-scoped, inherited from the owning user,
and that is the INTENDED shape per operator ruling: infra-ops manages the
instance, so the key needs the rights the management work requires. A
least-privilege dedicated-user split was offered and declined. Do not
re-propose it.
Both credentials vaulted at esh-docker-vm/open-webui-{admin,api-key}; key verified
against /api/v1/auths/ (admin identity) and /api/models (33 models), with an
unauthenticated 401 control. Plaintext copies shredded.
Also folds in an unadjudicated drift found in passing: the running image is the
floating ghcr.io/open-webui/open-webui:main, not the v0.11.0 the stack notes
claim. Replaces the now-resolved 'operator's OWUI password change' follow-up.
|