atlas-iac/docs/hermes_multiuser_capacity_assessment.md
Hermes Agent d8ccf4a6fa docs(hermes): add multi-user chat capacity assessment
Read-only investigation of the chat request path, replica/resource
manifests, and live node metrics. Documents the confirmed 4-user
tenant-slot ceiling, a TENANT_SLOTS=8 vs replicas=4 configuration
drift, the shared Claude-broker concurrency=2 bottleneck, missing
HPA/PDB/staging environment, and a proposed SLO/load-test and staged
scaling plan pending human approval. No production manifests changed.

Co-Authored-By: Hermes Agent <hermes-automation@bstein.dev>
2026-08-21 12:38:02 +00:00

350 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Hermes multi-user capacity assessment
Status: read-only investigation, no production changes made. This document
answers "can Hermes safely handle more simultaneous users" with evidence from
the current `titan-iac` manifests/code, plus non-invasive live cluster reads.
It does not increase any replica count or concurrency cap.
## 0. Method and access boundary
- Static evidence: `services/hermes/**` manifests and `services/hermes/router/*.go`,
`services/hermes/scripts/*.py` on branch `wt/t_65356568`, `git log`/`git blame`
for recent drift.
- Live evidence: read via the in-cluster `atlas-observer` kubeconfig context.
This identity (`system:serviceaccount:hermes:hermes-agent`) is **observer-only**:
`kubectl -n hermes get pods/deploy/sts/hpa/pdb` all returned `Forbidden`.
`kubectl -n hermes auth can-i --list` confirms the only granted verbs are
cluster-scoped `get/list/watch` on `namespaces`, `nodes`, `persistentvolumes`,
storage APIs, and `nodes.metrics.k8s.io`, plus API discovery/health endpoints.
There is no namespace-scoped read access to pods, deployments, replica counts,
or pod-level resource usage in `hermes` (or any namespace) from this session.
This matches the standing finding that hermes-agent RBAC is observer-only.
- What this means for this report: **all replica counts, resource
requests/limits, probes, and affinity rules below are read from the Git
manifests** (which is what Flux actually reconciles, so it is a trustworthy
source for desired state) rather than confirmed against live pod status.
**Node-level capacity and current node CPU/memory utilization are live,
real-time cluster evidence** (`nodes.metrics.k8s.io` + `nodes` are both
readable). One non-invasive HTTP probe against `https://chat.bstein.dev/`
confirmed the public endpoint is live and redirecting to auth (`302`) at
the time of writing.
- No pods, deployments, config, or replica counts were changed. No load test
was executed against any environment.
## 1. Architecture and request path
```
browser
-> Traefik Ingress (chat.hermes.bstein.dev / chat.bstein.dev)
-> oauth2-proxy-hermes-chat (Deployment, replicas: 1, oauth2-proxy.yaml:153)
session state: Redis, externalized (--session-store-type=redis,
redis://hermes-oauth-sessions...:6379/1, oauth2-proxy.yaml:203-204)
-> hermes-chat-router (Deployment, replicas: 1, chat-router.yaml:10)
Go binary, services/hermes/router/main.go
in-process, PVC-persisted identity -> slot map (tenants.json)
-> hermes-chat-tenant-<slot>.hermes-chat-tenant (StatefulSet, replicas: 4,
chat-statefulset.yaml:13) — one pod per Keycloak subject, pinned by ordinal
webui (8787) -> gateway (8642) -> per-tenant sandbox (chat-sandbox-<ordinal>:9080)
-> hermes-switchyard (Deployment, replicas: 1, switchyard-deployment.yaml:10)
shared model router for ALL tenants + triage + Kanban
-> hermes-claude-broker / hermes-codex-broker (containers in hermes-agent pod,
replicas: 1, agent-deployment.yaml:10) / hermes-model-gate (Deployment,
replicas: 1, model-gate-deployment.yaml:10, proxies to local Ollama)
-> hermes-stt / hermes-tts (Deployments, replicas: 1 each, voice-deployment.yaml,
pinned to node titan-21, shared GPU)
```
Every component on this path is `replicas: 1` **except** `hermes-chat-tenant`
(4). The StatefulSet is not a load-balanced pool: each pod is a fully isolated,
permanently-assigned per-user backend, not interchangeable capacity.
## 2. Known limits (confirmed from manifests/code)
### 2.1 Hard ceiling: 4 usable chat users today, and a live configuration bug
`services/hermes/router/main.go` (`tenantRouter.slotFor`, lines 153-176)
permanently assigns each Keycloak subject a slot `0..TENANT_SLOTS-1` on first
login, persists it to a PVC-backed JSON file, and never releases it. There is
no unassign/deprovision path in the router. Unit tests
(`router/main_test.go`, "expected the fixed private tenant pool to report
capacity") assert that once all slots are taken, `slotFor` returns an error.
**Confirmed drift**: `chat-router.yaml:70` sets `TENANT_SLOTS=8`, but
`chat-statefulset.yaml:13` sets `replicas: 4`. `git log -p` on both files
shows they were changed together at `18965a2f` (4->8), then
`chat-statefulset.yaml` was scaled down twice — `79baa7ec` ("return two chat
slots to fit the agent runtime", 8->6) and `e28b32bd` (6->4) — **without a
matching change to `TENANT_SLOTS`**, and `chat-sandbox.yaml` still defines 8
per-ordinal sandbox Deployments (`hermes-chat-sandbox-0` through `-7`).
Net effect: the 5th8th distinct Keycloak login is durably assigned a slot
that has no backing StatefulSet pod. `main.go`'s proxy error path (around
line 382) returns a "starting" error to that user **permanently**, not just
during a cold start, because the pod will never exist at the current replica
count. This is a pre-existing configuration bug, independent of any future
scale-up decision, and it silently masks the real behavior of the capacity
ceiling (a 5th user does not get a clean "at capacity" rejection matching the
tested behavior — they get a StatefulSet-pod-not-found style failure).
**This assessment does not fix it** (per scope: no production changes), but
it should be the first fix considered before any scale-out, since it affects
correctness at the current replica count too.
### 2.2 The real shared bottleneck is not the 4 tenant pods, it's the brokers behind them
All 4 tenant pods, the triage/operator instance, and Kanban/CLI-lane traffic
funnel through the same single-replica `hermes-switchyard`, which routes to
the same single-replica `hermes-claude-broker` / `hermes-codex-broker`
(containers inside the one `hermes-agent` pod) or `hermes-model-gate`.
- `hermes-claude-broker` enforces a hard, code-level concurrency cap:
`HERMES_CLAUDE_BROKER_CONCURRENCY=2` (`agent-deployment.yaml:1160`) sizes a
`threading.BoundedSemaphore(2)` (`scripts/claude_oauth_broker.py:63`)
wrapped around a blocking `subprocess.run` call to the native Claude Code
CLI. Requests over the cap **block silently** (no 429/503 to the caller)
until a slot frees. This cap is shared cluster-wide across every chat
tenant, the triage instance, and indirectly the Kanban/CLI-lane workers
(same provider credentials, so heavy CLI-lane usage can throttle chat
responses via the provider's own rate limits even without touching this
semaphore directly).
- `hermes-codex-broker` and `hermes-model-gate` have **no equivalent
concurrency cap in code** — they are bounded only by container CPU/memory
`limits` (1 CPU / 1Gi for the codex-broker container, 250m/128Mi for
model-gate) and by the single upstream Ollama/Jetson instance's real
throughput, which is unknown (no load test exists).
**Implication**: scaling `hermes-chat-tenant` replicas alone (e.g. 4 -> 8)
does not proportionally increase real AI-response throughput. The
Claude-routed share of traffic is capped at 2 concurrent completions
regardless of how many tenant pods exist, and Codex/local-model throughput
under concurrent load is untested.
### 2.3 Single points of failure and deploy-time outage windows
- No `HorizontalPodAutoscaler` exists anywhere in this repository (confirmed
by a repo-wide grep, not just `services/hermes/`).
- No `PodDisruptionBudget` exists for any hermes component (the one PDB in
the repo is `infrastructure/traefik/pdb.yaml`, unrelated).
- `oauth2-proxy`, `hermes-chat-router`, `hermes-switchyard`,
`hermes-model-gate`, `hermes-agent` (brokers), and voice STT/TTS are all
`replicas: 1`. A pod restart/crash/node drain on any of them takes down
chat, voice, or model access for **all** users simultaneously, even though
the tenant StatefulSet gives each user an isolated conversation backend.
- `hermes` (main/triage), `hermes-chat-router`, `hermes-local-image`, and
`hermes-agent` all use `strategy: Recreate` — every rollout is a full
teardown-then-recreate, not a rolling update, adding a real availability
gap bounded by readiness/liveness `initialDelaySeconds` (up to 90s+ for the
main `hermes` deployment). The only genuinely zero-downtime rollout path
in this whole service is `hermes-switchyard`
(`RollingUpdate`, `maxSurge:1`, `maxUnavailable:0`) and the tenant
StatefulSet itself (`RollingUpdate`, `podManagementPolicy: Parallel`).
- OAuth session storage (`hermes-oauth-sessions`, a single Redis
`Deployment`, `replicas: 1`, `oauth-session-store.yaml`) has no
replication; its loss logs out every user cluster-wide (sessions are
externalized from oauth2-proxy but not made highly available).
### 2.4 The cluster is a heterogeneous home-lab/edge fleet with real, uneven headroom
Live `nodes.metrics.k8s.io` reads (non-invasive) show 21 nodes: mostly
Raspberry Pi 4/5 workers (~3.6-4 vCPU / ~6.5-8GB allocatable each), plus a
few larger amd64/Jetson-class nodes with shared GPUs (titan-20/21, titan-22,
titan-24) and one large amd64 node (titan-23, 48 vCPU/256GB, no GPU, not
labeled `worker`). This is **not** elastic cloud capacity — headroom is
finite and already uneven:
| Node | CPU used/alloc | Mem used/alloc | Note |
|---|---|---|---|
| titan-12 | 3.95 / 3.60 (**110%**) | 5.65 / 6.50 (87%) | rpi4, already over its CPU allocatable at snapshot time |
| titan-13 | 3.45 / 3.60 (96%) | 5.85 / 6.50 (90%) | rpi4, hot |
| titan-17 | 2.71 / 3.60 (75%) | 5.54 / 6.50 (85%) | rpi4, hot on memory |
| titan-07 | 1.23 / 3.60 (34%) | 5.24 / 6.63 (79%) | rpi5, memory-constrained |
| titan-04 | 0.35 / 3.60 (10%) | 1.69 / 6.63 (26%) | rpi5, most headroom in the pool |
| titan-21 (voice, GPU) | 0.91 / 6.00 (15%) | 8.02 / 14.56 (55%) | shared GPU node, moderate headroom |
| titan-23 (48 vCPU/256GB, no GPU label) | 0.74 / 48 (1.5%) | 22.9 / 251.6 (9%) | large headroom, not currently used by hermes affinity rules (arm64-only selectors exclude it) |
| titan-24 (GPU, Wolf/Hermes shared) | 1.16 / 24 (4.8%) | 22.6 / 62.7 (36%) | model-gate/Ollama contention with Wolf, per NOTES.md |
`chat-statefulset.yaml`'s node affinity (lines 54-74) requires `arm64` +
`node-role.kubernetes.io/worker=true` and already excludes 7 specific
hostnames (`titan-05,08,13,14,17,18,19`) — but two of the nodes it currently
*can* still land on, **titan-12 and titan-15, are already running at 75-110%
of allocatable CPU**. Any additional tenant/broker/router replica scheduled
into this same arm64 worker pool inherits that contention; it is not safe to
assume "add a replica" has free capacity behind it without checking node
headroom at scale-out time. The large amd64 node (titan-23) has abundant
headroom but is excluded by the current `arm64`-only affinity rules on every
hermes component — worth a design question, not something this assessment
changes.
### 2.5 No staging/canary environment exists
`services/hermes/kustomization.yaml` is a single flat kustomization with no
overlay split. `services/hermes-chat/` contains only a namespace and one PVC
(vestigial, no workload). `services/hermes-triage-demo/` is unrelated
(Ariadne's automated-repair demo loop, no chat workload). **Any load test
that exercises the real chat path runs against the production namespace and
the production `hermes-chat-tenant` StatefulSet** — there is no isolated
copy to test against.
### 2.6 No load-test tooling and thin production metrics exist today
- No k6/locust/vegeta/wrk/artillery/ab scripts exist anywhere in the repo
for hermes. A load test has to be built from scratch.
- No `ServiceMonitor`/`PodMonitor`/`PrometheusRule` objects exist for hermes
(annotation-based scraping only). `/metrics` exists for the `hermes-agent`
pod (CLI/provider quota usage, port 9010), `hermes-switchyard` (port 9005,
the richest signal — `switchyard_requests_total`,
`switchyard_model_call_latency_ms_bucket`,
`switchyard_routing_overhead_ms_bucket`, `switchyard_errors_total`,
`switchyard_client_responses_total`), and the CLI-lane metrics service
(port 9011, agent/Kanban-lane specific). **No `/metrics` endpoint exists
on `hermes-chat-tenant`, `hermes-model-gate`, `oauth2-proxy`,
`hermes-chat-router`, or voice** — there is no per-request latency/error
signal from the actual multi-user chat pods themselves.
- `services/monitoring/grafana-dashboard-ai.yaml` already has a P95
model-call-latency panel, a success/error-rate panel, and a per-pod CPU
panel filtered to `hermes-(agent|chat-tenant|switchyard|model-gate).*`,
all sourced from Switchyard metrics + cAdvisor — a usable baseline to
reuse, but nothing is wired to an alert threshold, and there is no
queue-depth or replica-health panel.
## 3. Unknowns (require an actual test to answer)
- Real max throughput of `hermes-switchyard`, `hermes-model-gate` -> Ollama,
and the STT/TTS GPU path under concurrent load — no data exists today.
- Real behavior/latency of the Claude broker's semaphore-2 queue under
contention (does it degrade gracefully to slow responses, or eventually
time out and error?) — no data exists today.
- Whether oauth2-proxy + Redis session store can handle concurrent
logins/refreshes at a higher user count (currently untested; the
Deployment is stateless-safe to scale, but has never been scaled).
Also whether Redis itself (`hermes-oauth-sessions`, 1Gi PVC, no HA)
becomes a bottleneck or SPOF under more simultaneous sessions.
- Actual current pod-level CPU/memory usage and restart history for any
hermes component — not observable with the current RBAC grant. Only
node-level aggregate usage is visible, not which pods are consuming it.
- Whether scaling `hermes-chat-tenant` toward its already-configured
`TENANT_SLOTS=8` (after fixing the mismatch) is actually safe given
current node headroom (titan-12/titan-15 pressure, see 2.4) — needs a
scheduling dry-run / headroom check at decision time, not assumed from
this snapshot.
## 4. SLO and load-test proposal (not yet executed — needs approval, see §6)
### 4.1 Target SLOs to validate (proposed, for review)
| Metric | Proposed target | Source |
|---|---|---|
| P95 end-to-end chat response latency | < 8s for local/Codex-routed, < 20s for Claude-routed (queued behind semaphore-2) | new synthetic client timer + existing `switchyard_model_call_latency_ms_bucket` |
| Error rate (5xx / proxy failures) | < 1% sustained | new synthetic client + `switchyard_errors_total` / `switchyard_client_responses_total` |
| Login/session success rate | 100% (oauth2-proxy + Redis) | synthetic client HTTP status |
| Node CPU/mem headroom during test | stay below 90% allocatable on any node hosting hermes pods | `nodes.metrics.k8s.io` (already provably readable) |
| Claude-broker queue depth / wait time | document actual behavior, no target yet (unknown baseline) | needs new instrumentation none exists today |
### 4.2 Concurrency / ramp / mix proposal
- **Concurrency levels**: 1 (baseline), 2 (at the Claude-broker cap), 4
(at today's real tenant-pool ceiling), and only if §2.1's mismatch is
resolved and node headroom is re-checked 8 (at the configured
`TENANT_SLOTS`). Do not exceed the number of actually-backed tenant slots.
- **Ramp**: hold each level for at least 10 minutes after reaching steady
state before increasing, to separate cold-start effects (webui/gateway
startup, model warm-up) from steady-state capacity.
- **Traffic mix**: proportion requests across the three model routes
(Claude / Codex / local Ollama) matching real usage if known, otherwise
test each route in isolation first (to characterize each bottleneck
independently semaphore-2 vs. no-cap-but-untested), then a blended mix.
- **Session behavior**: each synthetic user must be a distinct, dedicated
test Keycloak identity (slot assignment is permanent do not burn real
user slots or reuse production accounts for load generation).
- **Failure injection (optional, staged)**: kill the single
`hermes-switchyard` or `hermes-model-gate` pod mid-test to confirm the
`Recreate`/no-PDB SPOF behavior matches expectations and measure recovery
time, only after the throughput characterization above is complete.
## 5. Safe staged scaling plan (for future execution, after human approval)
This is a plan to evaluate, not a plan already executed.
1. **Fix the `TENANT_SLOTS`/replica-count mismatch first** 2.1). This is a
correctness fix at the *current* capacity, not a capacity increase, and
should land before any load test so results aren't confounded by the
existing 502-on-phantom-slot bug.
2. **Instrument before scaling**: add `/metrics` (or at minimum structured
access logs) to `hermes-chat-tenant`, `hermes-chat-router`, and
`hermes-model-gate` so a load test has a real per-request signal instead
of relying solely on Switchyard's aggregate view.
3. **Baseline test at today's real ceiling (4 users)**, off-hours, using
dedicated test Keycloak identities, per §4.
4. **Re-check node headroom** 2.4) at decision time, not from this
snapshot, before considering any StatefulSet scale-out titan-12 and
titan-15 were already at/above CPU allocatable during this assessment.
5. **If and only if** the baseline is healthy and headroom allows, propose
scaling `hermes-chat-tenant` toward the already-configured 8 slots one
or two ordinals at a time (StatefulSet `podManagementPolicy: Parallel`
supports this), re-measuring node headroom and Switchyard/broker latency
after each step.
6. **Address the shared-broker ceiling separately from tenant-pod count**:
raising `HERMES_CLAUDE_BROKER_CONCURRENCY` above 2 is a distinct decision
gated by the underlying Claude Code provider's real rate limits, not by
Kubernetes resources do not conflate it with StatefulSet scaling.
7. **Only after tenant/broker capacity is validated**, consider whether the
single-replica shared components (`hermes-switchyard`, `hermes-model-gate`,
`oauth2-proxy`) need a second replica or a PDB note oauth2-proxy is the
only one of these that is currently stateless-safe to scale (Redis-backed
sessions); the others would need code changes first (in-process locks/
state noted in §2).
## 6. Rollback and observability gates
- Every step above is a Git-reviewed manifest change applied via Flux, so
rollback is `git revert` + Flux reconcile, consistent with this repo's
existing GitOps model no manual `kubectl edit` in any step.
- Gate each scaling step on: node CPU/mem headroom 2.4 thresholds), the
new `/metrics` signal from step 2 above staying within the SLOs in §4.1,
and zero increase in `switchyard_errors_total` / `switchyard_classifier_fail_open_total`
rate versus the pre-change baseline.
- Because there is no staging environment 2.5) and no PDB, treat every
step as a production change: schedule off-hours, announce before/after,
and keep the prior manifest revision ready to revert immediately if error
rate or node pressure crosses the gate.
- Because RBAC here is observer-only 0), whoever executes a future load
test needs either elevated read access to confirm live pod/replica status
during the test, or must rely entirely on the node-metrics + Switchyard
signals already proven readable in this assessment.
## 7. Recommendation
**A load test against the current 4-user ceiling is reasonably safe to run
off-hours with dedicated test accounts, once §5 step 1 (the `TENANT_SLOTS`
mismatch) and step 2 (baseline metrics) are addressed** it does not
require any replica/cap increase, and the blast radius is bounded to
resources already provisioned.
**A test at 8 concurrent users, or any StatefulSet/broker scale-up, is not
yet safe to schedule.** Two of the nodes in the current chat-tenant affinity
pool are already at or above their allocatable CPU/memory 2.4), there is
no PDB/HPA anywhere in this service, there is no staging environment to
absorb the risk, and the real throughput ceiling for Codex/local-model
routes and the actual behavior of the Claude-broker queue under load are
both unmeasured. Recommend running the 4-user baseline test first and using
its results (plus a fresh node-headroom check) to decide whether 8-user
scaling is warranted this assessment does not make that call.
## 8. Requested input / blockers to proceed further
This assessment can be delivered without further input. To actually execute
the load test proposed in §4, a human owner needs to provide or approve:
1. Sign-off to fix the `TENANT_SLOTS`/replica mismatch 2.1) as a
correctness PR, separate from any capacity increase.
2. Dedicated test Keycloak identities (slot assignment is permanent
production user accounts must not be used for load generation).
3. An approved off-hours maintenance window, since there is no staging
environment and every step in §5 is a production change.
4. A decision on whether to add `/metrics` instrumentation 5 step 2)
before or in parallel with the first baseline test, since today's
Switchyard-only signal cannot attribute latency/errors to a specific
tenant pod.
5. If deeper live verification is wanted beyond node-level metrics
(e.g., live pod status/restart counts during a test), temporary
elevated read RBAC for the executing identity, since the current
`atlas-observer` grant cannot list pods/deployments in `hermes` 0).