fix(config): preserve platforms on partial save_config writes (#62723)

Add merge_existing to save_config (default False for full-document callers
like the dashboard YAML editor) and route partial writes through
_merge_partial_save. _persist_migration writes the full migrated dict
directly so deleted keys are not resurrected from the on-disk file.
This commit is contained in:
HexLab98 2026-07-12 06:54:56 +07:00 committed by Teknium
parent 569b912d7d
commit 0ab90040ad
2 changed files with 44 additions and 6 deletions

View file

@ -5524,10 +5524,13 @@ def _persist_migration(config: Dict[str, Any]) -> None:
Every migration step MUST route its write through this helper instead of
calling ``save_config`` directly. It is a thin wrapper over
``save_config(config)`` (default-stripping ON); centralising the call makes
the invariant impossible to regress one migration at a time. Correctness
across seeds, non-default values, behaviour flips, and data transforms is
verified by the migration parity tests.
``save_config(config)`` (default-stripping ON, no ``merge_existing``);
centralising the call makes the invariant impossible to regress one
migration at a time. Callers must pass the full raw config returned by
``read_raw_config()`` after in-place mutations (including key removals);
deep-merging the on-disk file back in would resurrect keys the migration
just deleted. Partial-save preservation for unrelated top-level sections
belongs on ``save_config(..., merge_existing=True)``, not here.
"""
save_config(config)
@ -6302,6 +6305,25 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
return results
def _merge_partial_save(raw: dict, override: dict) -> dict:
"""Merge *override* over *raw* for partial ``save_config`` writes.
Top-level sections omitted from *override* are preserved from *raw*.
Shared top-level dict sections are deep-merged so a caller can update one
nested key without dropping sibling keys from disk. Intentional key
removals within a section are not supported here migration writes must
route through ``_persist_migration`` with a full ``read_raw_config()`` dict
instead.
"""
result = copy.deepcopy(override)
for key, value in raw.items():
if key not in result:
result[key] = copy.deepcopy(value)
elif isinstance(result.get(key), dict) and isinstance(value, dict):
result[key] = _deep_merge(value, result[key])
return result
def _deep_merge(base: dict, override: dict) -> dict:
"""Recursively merge *override* into *base*, preserving nested defaults.
@ -7184,6 +7206,7 @@ def save_config(
*,
strip_defaults: bool = True,
preserve_keys: Optional[Set[Tuple[str, ...]]] = None,
merge_existing: bool = False,
):
"""Save configuration to ~/.hermes/config.yaml.\n
@ -7192,6 +7215,12 @@ def save_config(
before any normalisation). This prevents config.yaml from being
contaminated with schema defaults on every save, which makes future
default changes invisible to users.
When ``merge_existing`` is True, the on-disk raw config is deep-merged
under *config* before writing so partial callers (migration steps via
``_persist_migration``) cannot drop unrelated sections the caller omitted.
Full-document replacement callers (dashboard raw YAML editor, callers that
already deep-merge) must leave this False so intentional deletions survive.
"""
with _CONFIG_LOCK:
if is_managed():
@ -7226,11 +7255,17 @@ def save_config(
explicit_raw_paths: Optional[Set[Tuple[str, ...]]] = (
_explicit_config_paths(_raw_for_paths) if _raw_for_paths else None
)
if merge_existing and _raw_for_paths:
config = _merge_partial_save(_raw_for_paths, config)
# ----------------------------------------------------------------
current_normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))
normalized = current_normalized
raw_existing = _normalize_root_model_keys(_normalize_max_turns_config(read_raw_config()))
raw_existing = (
_normalize_root_model_keys(_normalize_max_turns_config(_raw_for_paths))
if _raw_for_paths
else {}
)
if raw_existing:
normalized = _preserve_env_ref_templates(
normalized,
@ -7278,6 +7313,7 @@ def save_config(
extra_content="".join(parts) if parts else None,
)
_secure_file(config_path)
_RAW_CONFIG_CACHE.pop(str(config_path), None)
_LAST_EXPANDED_CONFIG_BY_PATH[str(config_path)] = copy.deepcopy(current_normalized)

View file

@ -14148,7 +14148,9 @@ async def update_config_raw(body: RawConfigUpdate, profile: Optional[str] = None
if not isinstance(parsed, dict):
raise HTTPException(status_code=400, detail="YAML must be a mapping")
with _profile_scope(body.profile or profile):
save_config(parsed)
# Full-document replacement: the editor owns the whole file; do not
# merge omitted sections back from disk (#62723).
save_config(parsed, merge_existing=False)
return {"ok": True}
except yaml.YAMLError as e:
raise HTTPException(status_code=400, detail=f"Invalid YAML: {e}")