Operator-tier settings.yaml doesn't hot-reload — extend the existing project-tier mechanism
Nobody has claimed this yet.
- Dominant language
- Go
- Stars
- 152
- Forks
- 16
- Avg merge
- 14h 48m
- Merged PRs (30d)
- 536
Description
Problem
Project-tier config (.mecatl/settings.yaml) already hot-reloads: permconfig.Resolver.Resolve() re-Stats every project file on each call and only reparses on mtime/size change (internal/adapter/permconfig/resolve.go stampSources/stampsEqual, ~line 437). An admin edit takes effect on the next turn, no restart.
Operator-tier config (~/.config/mecatl/settings.yaml, or its k8s ConfigMap-mounted equivalent) does not:
- Permission rules (
loadUserRules, resolve.go ~389-406) are parsed exactly once, atResolverconstruction, and never re-read. - Everything else operator-tier (posture, guardrails, model slots, reasoning-effort, OpenRouter routing, plan-mode-auto-approve) is likewise computed once inside
loadUserRules'scapture*calls and exposed via plain getters (OperatorPosture(),OperatorGuardrails(), etc., resolve.go ~199-260) that read a field set once and never touched again.sessionEngineFactory(internal/app/build.go~2210) closes over these values viaapp.Build()'s once-onlyfoldOperator*calls (~1249-1345). Only a full process restart picks any of this up.
On k8s (cmd/mecak8s), this file is delivered via ConfigMap and reads identically to the bare-metal path (cmd/mecak8s/flags.go) — there's no k8s-specific mechanism at all today, and none is needed in the application: a projected (non-subPath) ConfigMap volume mount is a real file with a real mtime, updated in-place by kubelet's periodic ..data symlink swap. The fix below works identically for both binaries with zero k8s-specific code — but the k8s deployment manifest has its own hard requirement (see Deployment requirements below).
Proposed change
One reloadable operator snapshot, not two mechanisms. The Resolver gets a single atomically-swapped snapshot — rules and every capture*-derived knob (guardrails/posture/model-slots/reasoning-effort/openrouter/learning/mcp) together — re-derived from loadUserRules whenever the operator file's stamp changes. (An earlier draft of this issue tried to split "reload the rules" from "reload the knobs" into two separate mechanisms; that's wrong — the knobs are getters over fields written once by the same loadUserRules call the rules come from, so they have to reload together or the getters keep returning stale values forever.)
- Extend
stampSources-style mtime revalidation to the operator file(s). - On a stamp change, re-run
loadUserRulesand atomically swap in a new snapshot (atomic.Pointer[operatorSnapshot]+ a generation counter) containing both the rule set and the captured knobs. - Not folded into the existing per-root cache map — operator state is root-independent; bolting it onto that map would let a warm root silently keep serving a stale snapshot forever.
- Preserve "stat before read" ordering — this is what makes a losing race self-healing instead of permanently stale (a stale write is stamped stale too, so the next call reloads again).
- Every
Operator*()getter reads off the live snapshot pointer instead of an individually-frozen field. sessionEngineFactorycallsresolver.Operator*()(not the frozencfgfields it currently closes over) at the moment each new session is constructed, so it genuinely sees the latest snapshot.- Shared long-lived resources (the global MCP manager, catalogs, provider registries) are untouched — nothing about them is rebuilt.
Non-goals
- No new port/interface (
port.SettingsSourceor similar). Reuse the existing file-stat idiom; only build a real port if a non-file backend (Redis/etcd) is actually requested. - No fsnotify / Kubernetes watch API / Redis pub/sub. Poll-on-read is sufficient; the ConfigMap sync period (~1-2 min) is the real propagation floor regardless of poll rate.
- No forced-rollout automation, no third-party controller (e.g. Stakater Reloader). Not needed once the above lands — propagation no longer requires a restart.
kubectl rollout restartremains available as a manual, documented runbook step for the one case it's still useful: forcing an already-running session's process to also pick up a change, which is explicitly not a goal here. - No change to running sessions. A session's engine Deps (provider, model, posture, guardrails, reasoning-effort) are fixed at construction and stay fixed for its lifetime — mirrors the existing "Provider is FIXED per session" invariant.
- No re-evaluation of a parked human approval (
PendingAsk) against a reloaded snapshot. It resolves against the snapshot that existed when it was created, by design. - No
.d-directory / multi-file drop-in support. Separate ask, unrelated to reload. - No change to
internal/adapter/workspacetrust(trustedWorkspaces:in the same file) — it already reads fresh (no cache) on every call.
Deployment requirements (k8s)
- The operator settings ConfigMap must be mounted as a whole-directory projected volume, never via
subPath. AsubPathmount does not receive kubelet's atomic..datasymlink update at all — it silently never refreshes until the pod restarts, which defeats this entire feature while appearing to work at initial deploy. Document this explicitly in the k8s deployment guide (and consider a startup log line frommecak8sif asubPath-style static mount can be detected).
Acceptance criteria
- Editing the operator
settings.yaml'spermissions:block is reflected in the very next tool-call authorization, in any session (running or new), on bothmecatedandmecak8s, with no restart. - Editing posture/guardrails/model-defaults is reflected in the next new session created after the edit; a session already running when the edit landed is unaffected.
- Multi-replica propagation is per-pod, not fleet-wide. With N replicas of
mecak8s, different replicas may observe the same edit at different times, skewed by up to the kubelet ConfigMap sync period plus this resolver's own poll cadence. A request routed to different replicas during that window may see different rules. This is accepted as a bounded eventual-consistency property of a permissions file, not a defect — but it must be stated, not implied away by "no restart." - A single
Resolve()(or snapshot read) call never returns a mix of pre-edit and post-edit operator state. - A warm per-root cache entry converges to the new operator snapshot within one subsequent
Resolve()call after an edit (no permanent staleness). - A parked
PendingAskresolves against the snapshot that created it, confirmed unaffected by a reload landing while it's pending.
References
internal/adapter/permconfig/resolve.go(Resolve,stampSources,stampsEqual,loadUserRules,Operator*getters)internal/app/build.go(foldOperatorPosture/foldOperatorGuardrails/foldOperatorModelSlots/etc.,sessionEngineFactory)- AGENTS.md — "Provider is FIXED per session"
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with internal/adapter/permconfig/resolve.go, tracing Resolve, stampSources, loadUserRules, and the Operator* getters. Then inspect internal/app/build.go, especially the foldOperator* calls and sessionEngineFactory, plus the k8s deployment guide requirements. Done means operator rules and settings reload atomically after edits, new sessions see updated values, existing sessions and PendingAsk behavior remain unchanged, and whole-directory ConfigMap mounting is documented.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go, kubernetes
- Domain
- backend, infrastructure
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 45/100