User report after the llamacpp app install: 'installed llama.cpp unknown', valid flags rejected (randomly per run), 'Model not found' for the HF downloader's own layout, and a systemd user-bus failure over SSH. Detective (real b10822 binary, FACT) found four independent causes: - version: llama-server --version prints to STDERR; detect_llama_version's 2>/dev/null swallowed it -> always 'unknown'. Now captures 2>&1 + accepts semver/build tokens (incl. build 1.2.3 edge) - validation: printf|grep -q under pipefail -> SIGPIPE rc=141 race randomly rejected flags present in the 59 KB --help. Now pipe-less grep (no race); 20x determinism regression test - model resolution: resolve_model accepted files only, but the HF downloader creates <models>/<repo>/file.gguf dirs. Now expands a dir with exactly one *.gguf (never silently picks; multi-gguf lists + errs) - port: llama.cpp default 8080 vs tool/adapter 8088; validation reliability means --port is now always pinned in the unit - user bus: headless/SSH sessions lack XDG_RUNTIME_DIR -> ensure_user_bus in lib/common.sh pre-flights all three systemctl --user tools with remediation text; pos ai server --no-unit direct-run escape hatch (pidfile) for boxes with no bus - find_llamacpp narrowed to llama-server/llama-server-cuda (bare 'server' fallback hazard); installer post-install sanity (version+help execute, symlink targets resolve) Architect decisions DQ1-DQ6 recorded. Tester: 4 new regression files (version-from-stderr, 25x flag-validation determinism, model dir expansion, bus pre-flight + E2E) + 3 fixture updates; suite 16 files / 269 checks. Verified: make gen idempotent; make check OK; make lint 0 FAIL, 0 WARN; make test 269/269 (~49s); bash -n clean; git diff --check clean.
17 KiB
AI Server Start Breakage — Root Cause + Fix Design Decisions
Date: 2026-09-06
Architect: Design pass over the Detective report AgentsReport/detective/2026-09-06_ai-server-breakage.md (root cause classified FACT).
Constraint: No code changes in this pass; decisions + ratified fix scope (F1-F7) for the Builder; test-fixture requirements for the Tester. Preserve the existing Bash tool architecture and the D-A..D-F stabilization decisions.
TL;DR
- DQ1 — Validation stays help-gated. The SIGPIPE race (F2) is the only validation defect; help-based validation is binary-reality-based and correct once deterministic. Version is cosmetic. "unknown" means: validate against real
--helpregardless, message without the version. NO version→capability map. [DECIDED] - DQ2 —
resolve_modelgains file-inside-dir expansion.$HF_DOWNLOAD_DIR/<name>/dir → exactly-one*.ggufresolves to it; multiple → list + err ("pick one"); zero → err as today. Also accept<name>/<file>.gguf. Precedence: absolute path > dir-with-exactly-one-gguf >$HF_DOWNLOAD_DIR/<name>.ggufflat file >$HF_DOWNLOAD_DIR/<name>/<file>> relative-as-is. Existing bare-file behavior unchanged (backward compatible). [DECIDED] - DQ3 — User-bus pre-flight by default, plus
--no-unitdirect-run escape hatch (option c). Pre-flight aborts BEFORE writing the unit (no orphan);--no-unitruns the server directly under nohup+pidfile for headless/SSH boxes. Shared helperensure_user_buslives inlib/common.sh(all three tools source it). Matrix-listener + network-download get the same pre-flight. Remediation text includesexport XDG_RUNTIME_DIR=/run/user/$(id -u)andsudo loginctl enable-linger $(id -un). [DECIDED] - DQ4 —
find_llamacppdrops bareserverandllama.cpp/server. Keepllama-server,llama-server-cuda. Do NOT addllama-server-mtl(Mac-only; out of target). Stale-unit: start F4 pre-flight WARNS about an existing unit, does not delete it (never gratuitously remove user state). [DECIDED] - DQ5 — Always pin
--port $PORT. Once F2 makes validation reliable, ExecStart always carries--port $PORT(help-gated, so a build without--portomits it). No adapter probing fallback change needed (deferred). [DECIDED] - DQ6 — Installer sanity IS in scope.
apps/ai/llamacpp.shpost-install runsllama-server --version(2>&1) +--helpand errs if the binary doesn't execute; verifies the symlink target exists. [DECIDED]
DQ1: Validation posture when version is unknown
Decision
Keep help-based validation as the single source of truth. Do NOT add a version→capability map. "unknown" means: still validate against the real --help output; only the version string in log/error messages is dropped (message proceeds without version).
Rationale
Help-based validation reflects what the actual installed binary supports — it is always correct once the SIGPIPE race (F2) is fixed, because it peers at real capability output. Adding a version→capability table would introduce a second, version-coupled source of truth that drifts the moment llama.cpp adds/renames flags; its only benefit would be to decorate messages with a version string, which is cosmetic. The version is useful for debugging ("which build am I running") but must never gate validation. "unknown" therefore degrades only to "no version in messages", not to "no validation".
How "unknown" behaves
detect_llama_versionreturns "unknown" only when--versionis genuinely unreadable (no binary) — after F1, stderr is captured and the output IS readable for real builds.- With "unknown",
validate_*_flagsstill fetch--helpand judge flags normally; error texts simply omit the version token. - F1's broadened regex handles both semver and build-only strings, so "unknown" becomes rare (only truly missing binary/--version).
[DELIVERY: F1 capture-2>&1 + broadened regex; F2 race fix. No capability map.]
DQ2: resolve_model directory expansion
Decision
Extend resolve_model (bin/pos-ai-server:230-264) to resolve a directory-typed explicit argument when it contains a usable model file, with exact precedence. Interactive pick_model stays recursive (already lists flat .gguf files one level up via find -type f; confirmed at :210 — it lists each dir's files, so no F3 change needed there).
Precedence (highest → lowest)
- Absolute path that is a file (
[ -f "$explicit" ]) — unchanged; if the absolute path is a directory, treat as directory case below. $HF_DOWNLOAD_DIR/<name>is a DIRECTORY containing exactly one*.gguf→ resolve to that file. If multiple*.gguf→ print all anderr "pick one: <name>/<file>". If zero → fall through / err (never silently pick).$HF_DOWNLOAD_DIR/<name>.ggufflat file — unchanged ([ -f "$candidate" ]).$HF_DOWNLOAD_DIR/<name>/<file>.gguf(slug/file form) — resolve to$HF_DOWNLOAD_DIR/<name>/<file>.gguf.- Relative-as-is — unchanged (
[ -f "$explicit" ]).
Backward compatibility
Bare existing behavior (flat file path, absolute file path, relative path) is fully preserved — the new directory-expansion branches only fire where the current code would have errored with "Model not found". No silent picking: multiple matches always err with the disambiguating list.
Exact rule (multiple-gguf dir)
if a dir contains >1 *.gguf: print each as "<dirname>/<file>" and err "model dir contains multiple — pick one"
This matches the fixture expectation (multi-gguf dir case errors with the file list).
[DELIVERY: F3 resolve_model expansion; no pick_model change.]
DQ3: User-bus failure handling (scope across tools)
Decision
Option (c): pre-flight err by default, --no-unit direct-run as escape hatch.
ensure_user_bushelper inlib/common.sh(the base lib already sourced by all three tools via the fallback chain; it is the natural shared home for a user-bus guard — the alterative, config-ui.sh, is a config/validation lib, not an execution helper, and would be a semantic misfit).- Wired into the unit-install paths of
bin/pos-ai-server:605,bin/pos-communication-matrix-listener:341, andbin/pos-network-download:191— all three call it BEFOREsystemctl --user daemon-reloadand BEFORE the unit is written, so a failure leaves no orphaned unit. - On failure,
ensure_user_buserrs with remediation text:export XDG_RUNTIME_DIR=/run/user/$(id -u)(if the dir exists)sudo loginctl enable-linger $(id -un)- plus the general "connect to the user's systemd bus" guidance.
Why nohup/pidfile for --no-unit (direct-run)
Headless/SSH boxes may lack a working user bus permanently; being unable to serve a model at all is worse than a supervisor-less process. --no-unit on pos ai server start execs llama-server directly under nohup ... >$log 2>&1 &, writes a pidfile under $RUN_DIR/$HF_DOWNLOAD_DIR sibling (e.g. $RUN_DIR/pos-ai-server.pid), and prints the log path + kill $(cat pidfile) hint. This is a thin escape hatch, NOT a second supervisor; supervision stays systemd when available.
Why the helper lives in common.sh (not config-ui)
- All three tools source
lib/common.shalready (via the$(dirname)/../lib/common.shfallback chain). Config-ui.sh is not guaranteed present in the standalone communication tools' spirit, and it is semantically a config lib. An execution pre-flight guard belongs with the otherrun/spawnexecution helpers in common.sh. - Same one-line helper (declared with a
declare -Fguard like user-timers-lib.sh does) so all three tools share remediation text and behavior, and it can be unit-tested once.
Minimal viable behavior for THIS pass
ensure_user_busin common.sh.- Pre-flight wired into pos-ai-server, matrix-listener, network-download before unit write.
--no-unitdirect-run flag on pos-ai-server only (the reported tool, and the only one whose unit manages llama-server). matrix-listener and network-download get the pre-flight but NO direct-run escape hatches this pass (their stateful daemons genuinely need systemd; a direct-run fallback would be a larger design change and is deferred).
[DELIVERY: F4 pre-flight + --no-unit on pos-ai-server.]
DQ4: find_llamacpp candidates
Decision
New candidate list: llama-server, llama-server-cuda only. Drop bare server and llama.cpp/server. Do NOT add llama-server-mtl.
Rationale
- Bare
serveris an unrelated-generic-name hazard (fixture-proven: picks an unrelated binary, then ALL defaults rejected) — genuinely dangerous, drop it. llama.cpp/serveris a relative path-like token thatcommand -vcan only match as a literal filenamellama.cpp/server— not a real on-PATH name for the archive's/usr/local/bin/llama-serverlayout; it provides no value, drop it.llama-server-cudais a genuine distinct binary name for some CUDA-series builds — keep it.llama-server-mtlis the Apple Metal (macOS) binary name; this repo targets Debian/Ubuntu Linux only (apps/ai/llamacpp.sherrors on non-x64/arm64) — out of scope.- If
llama-serverandllama-server-cudaare both absent, the existingfind_llamacpp-failureerralready carries the installer hint (unchanged).
Stale-unit handling on start (F4 interaction)
cmd_start pre-flight: if $USER_SYSTEMD_DIR/$SERVICE already exists, warn that a unit is present (it may be stale/orphaned from a prior failed start) and that it will be overwritten; proceed to write. Do NOT delete it. cmd_stop (:628-641) is the only stated removal path and remains the exclusive way user state is removed. Rationale: deleting on start would gratuitously discard user state (a running, working unit) and would blur the unit's ownership. The pre-flight bus check (DQ3) already prevents NEW orphans, so an existing unit is either intentional or stale-legacy — warn, never auto-remove.
[DELIVERY: F5 candidate list + start stale-unit warning.]
DQ5: Port pinning
Decision
Always pin --port $PORT in ExecStart (help-gated). Confirmed: the adapter probes and health check already use $PORT (default 8088; llama.cpp default is 8080 per common/common.h:620). After F2 makes default-flag validation reliable, --port is never spuriously omitted, so the server always binds the port the adapter and health check expect.
Mechanics
--port $PORTis a DEFAULT flag already emitted atbin/pos-ai-server:528guarded byDEFAULT_PORT_OK. Once F2 removes the race,DEFAULT_PORT_OKis reliably 1 for real builds (which advertise--portin--help), so--port $PORTis pinned.- Strictly ancient llama-server builds without
--portin--helpomit it (help-gated); in that (degenerate) case the adapter's 8088 probe would not match the server's 8080 default. Since such builds are not the supported real release (b10822 ships--port, default 8080), this residual path is accepted and documented as the "ancient build" degradation.
Explicitly deferred
- Adapter probing fallback (probe both 8088 and 8080, or derive port from the unit): NOT in scope this pass. With F2+F5, the server and adapter agree on 8088 by construction. Adding a dual-port adapter probe would mask (not fix) a genuine port disagreement and complicate the adapter for a non-path that F5 removes. Deferred with reason.
[DELIVERY: F6 port pinning — achieved via F2+F5; no adapter fallback.]
DQ6: Installer sanity (F7)
Decision
In scope. apps/ai/llamacpp.sh:46-57 post-install adds a sanity check after the symlink loop:
- Run
llama-server --versioncapturing2>&1—errif the binary doesn't execute (non-zero exit). - Run
llama-server --helpsimilarly —errif unreadable. - Verify the symlink target exists: for each
/usr/local/bin/llama*symlink created,[ -e "$link" ](resolves target) —errif broken.
Rationale
This catches a genuinely broken install (missing shared lib → binary won't run; truncated/empty archive → symlink dangling) at install time with one clear err, instead of surfacing as a confusing "version unknown / flags rejected" on the first pos ai server start. It is cheap and self-contained in the installer; it is the front door to the whole tool chain and is the natural place to fail fast.
[DELIVERY: F7 installer post-install sanity.]
Ratified Fix Scope (F1-F7)
| # | File:line | Decision | 1-line change |
|---|---|---|---|
| F1 | bin/pos-ai-server:71 |
KEEP | Capture 2>&1 and broaden regex to `[0-9]+.[0-9]+.[0-9]+ |
| F2 | bin/pos-ai-server:101,145 |
KEEP | Replace `printf |
| F3 | bin/pos-ai-server:230-264 |
KEEP | resolve_model dir-expansion per DQ2 precedence (exactly-one-gguf → resolve; multiple → list+err; commit $HF_DOWNLOAD_DIR/<name>/<file> form). |
| F4 | bin/pos-ai-server:605 + :461-626; bin/pos-communication-matrix-listener:341; bin/pos-network-download:191; new lib/common.sh |
KEEP (adjusted) | Add ensure_user_bus to lib/common.sh; call before unit write in all three tools (abort, no orphan); plus --no-unit direct-run flag on pos-ai-server; start pre-flight warns on existing unit (DQ4). |
| F5 | bin/pos-ai-server:56 |
KEEP | find_llamacpp candidates → ("llama-server" "llama-server-cuda"). |
| F6 | bin/pos-ai-server:528 (+ no adapter change) |
KEEP | Confirm --port $PORT always pinned (reliably emitted once F2 fixed); no adapter probing fallback. |
| F7 | apps/ai/llamacpp.sh:46-57 |
KEEP (in scope) | Post-install sanity: llama-server --version (2>&1) + --help execute, symlink targets exist, else err. |
Explicitly deferred (NOT this pass)
- Adapter probing fallback (probe 8088+8080 / derive port from unit) — F6 makes the adapter agree with the server by construction; a dual-port probe would mask (not fix) disagreement. Deferred with reason.
- Direct-run escape hatch for matrix-listener and network-download — stateful daemons genuinely need systemd; a direct-run supervisor is a larger design change. Deferred; only pos-ai-server gets
--no-unitthis pass. - Multi-user / TELEGRAM_GROUP_MODE and DQ-adjacent chat features — unrelated to this breakage; tracked separately in the stabilization design.
- D1 (pos-ai-hf single-file download failure recording) and LLAMACPP_HOST coherence (D2) — separate defects already tracked in the stabilization open-items; not part of this fix map.
--no-mmap-style substring false positives beyond the current word-boundary regex — D4 (already implemented in this file at:101); no change needed unless F2 rewrites it, which uses the identical word-boundary pattern.
Test-Fixture Requirements (for Tester)
Per fix from the Detective's fixture spec, deterministically:
- F1 (version): fake
llama-serverprintingversion: 0.4.0-dev (build 10822, commit …)to stderr, help to stdout → assertdetect_llama_versionreturns0.4.0(not "unknown"). Also a fixture printing onlyversion: b10822/build 10822to stderr → assert broadened regex returns the build token. - F2 (race + determinism): fake
llama-serverwhose--helpemits a 59 KB body with--threads@line 7,--ctx-size@25,--n-gpu-layers@140,--host/--portnear end (real llama.cpp layout). Runvalidate_default_flagsN≥20 times underset -euo pipefail→ fixed code reports all 5 supported on EVERY run (zero omissions). Regression tail: pre-fix code must fail at least once in the same loop (proves the race existed). - F3 (model dir): fixture
$HF_DOWNLOAD_DIR/Qwen-Qwen3-1.7B-GGUF/Qwen3-1.7B-Q8_0.gguf(+.hf-meta). AssertDRY_RUN=1 pos ai server start Qwen-Qwen3-1.7B-GGUFresolves to the file; multi-gguf dir case errors listing each asdirname/file; zero-gguf dir errors as today. - F4 (bus): env with
XDG_RUNTIME_DIR/DBUS_SESSION_BUS_ADDRESSunset (or stubsystemctlfailing with the dbus message) → assert pre-flighterrs with remediation text (bothexport XDG_RUNTIME_DIR=…andsudo loginctl enable-linger …), and no unit file is written (no orphan). Assert--no-unitpath execs the direct command via a stubllama-serverrecording argv + writes pidfile. - F5 (server fallback): PATH containing ONLY an unrelated fake
server(its own --help/--version), no llama-server → assertfind_llamacppdoes NOT returnserver. - F6 (port): with F2 fixed,
DRY_RUN=1generate a unit → assert ExecStart always contains--port 8088(and--host,--n-gpu-layers,--ctx-size,--threads). - F7 (installer): run
apps/ai/llamacpp.shagainst a fixture tar containing a broken binary (missing shared lib) → assert post-install sanityerrs. - E2E regression (reported user scenario): with stubs (fake
llama-serverb10822-shaped stderr + help, fake systemctl that succeeds only withXDG_RUNTIME_DIRset), run install→pos ai server start Qwen-Qwen3-1.7B-GGUF→assert the written unit is correct (ExecStart carries all 5 flags +--port 8088, no orphan unit, version resolvable).
Deliverables complete. See AgentsReport/architect/2026-09-06_stabilization-design.md (appended post-merge section) for the consolidated record.