config: stop settings reverting on reboot (broker save omission + models.json write race)

Two distinct "config goes back to what it was after restart" bugs:

1) save_config dropped broker websocket_path + websocket_ping_interval (and the
   new offload gpu_split/tensor_split). The settings UI set them in memory, but
   save_config never serialized them, so the next load() fell back to dataclass
   defaults — the broker protocol/websocket settings reverted every boot even with
   a fully persistent config dir. Now serialized.

2) Per-model fields (e.g. n_ctx) reverted via a multi-process models.json write
   race: every engine loads models.json at its own boot, so a SECONDARY engine's
   in-memory models_data is stale w.r.t. a later UI edit on the primary. When that
   secondary engine auto-persisted measured_vram_gb it called save_models(), which
   dumps its whole stale state and clobbered the edit. Added
   ConfigManager.persist_model_field(): a single-field, atomic read-modify-write
   that re-reads models.json from disk, updates only the one field, and refreshes
   the in-memory copy. record_vram_delta now uses it, so VRAM measurement can never
   revert user model config.
Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
parent c060e6dc
...@@ -615,7 +615,9 @@ class ConfigManager: ...@@ -615,7 +615,9 @@ class ConfigManager:
"ram_leak_watch": self.config.offload.ram_leak_watch, "ram_leak_watch": self.config.offload.ram_leak_watch,
"ram_watch_poll_seconds": self.config.offload.ram_watch_poll_seconds, "ram_watch_poll_seconds": self.config.offload.ram_watch_poll_seconds,
"ram_watch_soft_fraction": self.config.offload.ram_watch_soft_fraction, "ram_watch_soft_fraction": self.config.offload.ram_watch_soft_fraction,
"ram_watch_cuda": self.config.offload.ram_watch_cuda "ram_watch_cuda": self.config.offload.ram_watch_cuda,
"gpu_split": self.config.offload.gpu_split,
"tensor_split": self.config.offload.tensor_split
}, },
"vulkan": { "vulkan": {
"n_gpu_layers": self.config.vulkan.n_gpu_layers, "n_gpu_layers": self.config.vulkan.n_gpu_layers,
...@@ -701,12 +703,14 @@ class ConfigManager: ...@@ -701,12 +703,14 @@ class ConfigManager:
"client_id": self.config.broker.client_id, "client_id": self.config.broker.client_id,
"registration_token": self.config.broker.registration_token, "registration_token": self.config.broker.registration_token,
"advertised_endpoint": self.config.broker.advertised_endpoint, "advertised_endpoint": self.config.broker.advertised_endpoint,
"websocket_path": self.config.broker.websocket_path,
"transport": self.config.broker.transport, "transport": self.config.broker.transport,
"heartbeat_interval_seconds": self.config.broker.heartbeat_interval_seconds, "heartbeat_interval_seconds": self.config.broker.heartbeat_interval_seconds,
"connect_timeout_seconds": self.config.broker.connect_timeout_seconds, "connect_timeout_seconds": self.config.broker.connect_timeout_seconds,
"request_timeout_seconds": self.config.broker.request_timeout_seconds, "request_timeout_seconds": self.config.broker.request_timeout_seconds,
"reconnect_initial_delay_seconds": self.config.broker.reconnect_initial_delay_seconds, "reconnect_initial_delay_seconds": self.config.broker.reconnect_initial_delay_seconds,
"reconnect_max_delay_seconds": self.config.broker.reconnect_max_delay_seconds, "reconnect_max_delay_seconds": self.config.broker.reconnect_max_delay_seconds,
"websocket_ping_interval": self.config.broker.websocket_ping_interval,
}, },
"system_prompt": self.config.system_prompt, "system_prompt": self.config.system_prompt,
"tools_closer_prompt": self.config.tools_closer_prompt, "tools_closer_prompt": self.config.tools_closer_prompt,
...@@ -728,6 +732,47 @@ class ConfigManager: ...@@ -728,6 +732,47 @@ class ConfigManager:
"""Save models.json to disk.""" """Save models.json to disk."""
with open(self.models_path, 'w') as f: with open(self.models_path, 'w') as f:
json.dump(self.models_data, f, indent=2) json.dump(self.models_data, f, indent=2)
def persist_model_field(self, model_path: str, key: str, value) -> bool:
"""Set a SINGLE field on the matching model entry by RE-READING models.json
from disk first, then writing back — never dumping this process's whole
in-memory models_data.
This avoids a multi-process clobber: each engine (front + every backend
engine) loads models.json at its own boot, so a secondary engine's
in-memory copy is stale w.r.t. a UI edit (e.g. n_ctx) made afterward. When
that engine later auto-persists a runtime field (e.g. measured_vram_gb), a
plain save_models() would write its stale full state and revert the edit.
Read-modify-write of only the intended field keeps every other key intact.
Also refreshes self.models_data so this process sees the merged result."""
try:
on_disk = {}
if self.models_path.exists():
with open(self.models_path, 'r') as f:
on_disk = json.load(f)
except Exception:
on_disk = dict(self.models_data)
bare = model_path.split(":", 1)[1] if ":" in model_path else model_path
changed = False
for cat, lst in on_disk.items():
if not isinstance(lst, list):
continue
for entry in lst:
if not isinstance(entry, dict):
continue
epath = entry.get("path") or entry.get("id") or ""
if epath == bare or epath.split("/")[-1] == bare.split("/")[-1]:
if entry.get(key) != value:
entry[key] = value
changed = True
if changed:
tmp = str(self.models_path) + ".tmp"
with open(tmp, 'w') as f:
json.dump(on_disk, f, indent=2)
os.replace(tmp, self.models_path)
# Keep our in-memory copy consistent with the merged on-disk file.
self.models_data = on_disk
return changed
def save_auth(self): def save_auth(self):
"""Save auth.json to disk.""" """Save auth.json to disk."""
......
...@@ -2336,21 +2336,13 @@ class MultiModelManager: ...@@ -2336,21 +2336,13 @@ class MultiModelManager:
cfg = dict(cfg) if isinstance(cfg, dict) else {} cfg = dict(cfg) if isinstance(cfg, dict) else {}
cfg["measured_vram_gb"] = measured cfg["measured_vram_gb"] = measured
self.config[model_key] = cfg self.config[model_key] = cfg
# Persist to models.json via config_manager (separate field; the # Persist to models.json via a SINGLE-field read-modify-write, so a
# user's own keys are never touched). # secondary engine's stale in-memory models_data can't clobber a UI edit
# (e.g. n_ctx) made on the primary after this engine booted. Only
# measured_vram_gb on the matching entry is touched.
from codai.admin.routes import config_manager from codai.admin.routes import config_manager
if config_manager is not None: if config_manager is not None:
bare = model_key.split(":", 1)[1] if ":" in model_key else model_key config_manager.persist_model_field(model_key, "measured_vram_gb", measured)
for cat in ("text_models", "image_models", "audio_models", "tts_models",
"vision_models", "video_models", "audio_gen_models",
"embedding_models", "spatial_models"):
for entry in config_manager.models_data.get(cat, []):
if not isinstance(entry, dict):
continue
epath = entry.get("path") or entry.get("id") or ""
if epath == bare or epath.split("/")[-1] == bare.split("/")[-1]:
entry["measured_vram_gb"] = measured
config_manager.save_models()
except Exception as e: except Exception as e:
print(f" Warning: could not persist measured_vram_gb: {e}") print(f" Warning: could not persist measured_vram_gb: {e}")
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment