fix(config): save_config lost-update — three-way merge-preserve + loud CAS conflict refusal at the atomic_config_write seam - #62232
Open
benegessarit wants to merge 1 commit into
Conversation
… + CAS conflict refusal
save_config reserialized the caller's whole config dict over config.yaml.
Every caller mutates a snapshot from load_config(); any disk-side edit made
after that snapshot (a hand edit, another process's `hermes config set`, a
second gateway) was silently reverted on the next save — the classic lost
update. On multi-writer setups this recurringly reverts hand-edits, strips
comments, and flips keys the caller never touched.
Fix, at the atomic_config_write seam:
- save_config re-reads the disk and three-way merge-preserves against the
process's observed disk-state history (_DISK_STATE_OBSERVATIONS): keys the
caller did not change keep the disk's current value; foreign additions,
value changes, and deletions the caller never saw survive the save.
- The same key changed on both sides raises ConfigWriteConflictError:
loud, logged, fail-closed, nothing written. Paths only in the message —
config values can be secrets.
- In-process sequential saves stay last-write-wins on paths the process
itself authored (self-written state tracking), so flows like
fallback_add's restore-the-primary keep working.
- Duplicate mapping keys in the on-disk YAML raise ConfigDuplicateKeyError
instead of silently collapsing the duplicate stanza (YAML last-key-wins).
- Unknown second-level keys that near-match a known or sibling key WARN
(garble/typo detection); everything else stays silent — DEFAULT_CONFIG
is not a complete schema, at the top level or below.
- save_config now routes its write through atomic_config_write (the
fail-closed chokepoint) instead of calling atomic_yaml_write directly.
Preserved behaviors, covered by tests: ${ENV} template preservation,
schema-default stripping, the readable-file write guard, and the legacy
overwrite path when the on-disk YAML is unparseable (the last-known-good
retention seam keeps its .bak + serve-previous semantics).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
teknium1
reviewed
Jul 11, 2026
teknium1
left a comment
Collaborator
There was a problem hiding this comment.
Thanks for the detailed reproduction and for preserving the existing env-template and default-stripping cases. The stale-snapshot premise is real on this checkout: hermes_cli/config.py:7158 derives output from the caller snapshot and :7202 replaces the file.
Problems
- The proposed check is not a CAS across processes. PR
hermes_cli/config.py:7604reads the state used for the merge, but:7652later performs an unconditional write. Two processes can both read the same version, both find no conflict, and the later replacement loses the earlier write.utils.py:227-282supplies crash-safe replacement, not mutual exclusion. - The new tests stage a foreign disk edit, but do not force two writers past the pre-write read concurrently. This leaves the race above untested.
- The same full-file read/write pattern remains in
hermes_cli/config.py:8126-8164(set_config_value), outside the proposed merge seam.
Suggested changes
- Add cross-process serialization or a conditional-write protocol spanning read/merge/replace, with a barrier-based two-process regression test.
- Audit or route direct whole-config writers through the resulting seam, or narrow the claimed guarantee to
save_config.
Automated hermes-sweeper review.
| parts.append(_FALLBACK_COMMENT) | ||
|
|
||
| atomic_yaml_write( | ||
| atomic_config_write( |
Collaborator
There was a problem hiding this comment.
This write is not conditional on the state read at line 7604. Two processes can both merge against the same old disk state, then each call this replacement; the second silently loses the first process's update. Please serialize the fresh read through replacement across processes (or use a conditional-write equivalent) and add a barrier-based two-writer regression test.
13 tasks
This was referenced Aug 2, 2026
This was referenced Aug 15, 2026
100yenadmin
added a commit
to electricsheephq/evaOS-hermes-desktop-app-adapter
that referenced
this pull request
Aug 20, 2026
…ave cannot resurrect a pinned key `save_config` stripped managed-scope leaves from the caller's dict at the top of the function, before `_merge_partial_save` folded the on-disk document back in. For a partial save (`merge_existing=True`, e.g. the migration steps behind `_persist_migration`) the caller's dict does not carry the pinned key at all, so the strip found nothing to remove; the merge then re-imported the stale user value straight off disk and persisted it. No "managed setting(s) were not saved" notice was printed either, because nothing was stripped. Move the strip to just after the merge, so it sees the merged document. The `config set` single-key hard-reject path is unchanged. Impact: P3. While the managed pin is in force the runtime overlay still wins, so the effective configuration is correct and no user sees a wrong value today. The defect is that the stale user value is silently persisted to `~/.hermes/config.yaml` and would take effect if the administrator later removes the pin — a delayed, silent divergence rather than a live one. This does not touch NousResearch#62232's territory: the merge/CAS semantics of the atomic save seam are untouched. The change only reorders the managed-leaf strip relative to `_merge_partial_save`, both of which run before the write. Test: tests/hermes_cli/test_managed_scope_writeguard.py gains `test_bulk_merge_save_does_not_restore_existing_managed_leaf`, which pins `model.default` via the managed layer, seeds a user config carrying a stale `model.default` plus an unrelated section, runs a partial save, and asserts the pinned leaf is gone while the unrelated section and the new key survive. It also asserts the notice names the stripped key, which only fires if the strip runs after the merge. Receipts (tests/hermes_cli/test_managed_scope_writeguard.py): fail-before: 1 failed, 2 passed AssertionError: assert ('model' not in {'timezone': 'Asia/Bangkok', 'model': {'default': 'stale/user-model'}, 'x_unknown': {'keep': True}, 'agent': {}} or 'default' not in {'default': 'stale/user-model'}) pass-after: 3 passed Full tests/hermes_cli/: 6193 passed, 83 skipped, 1 failed — the one failure is test_service_manager.py::test_seed_supervise_skeleton_creates_expected_layout, which fails identically at the parent commit (macOS does not preserve the setgid bit the assertion expects) and is unrelated to this change. (cherry picked from commit a51fbb9)
100yenadmin
added a commit
to electricsheephq/evaOS-hermes-desktop-app-adapter
that referenced
this pull request
Aug 21, 2026
…ave cannot resurrect a pinned key `save_config` stripped managed-scope leaves from the caller's dict at the top of the function, before `_merge_partial_save` folded the on-disk document back in. For a partial save (`merge_existing=True`, e.g. the migration steps behind `_persist_migration`) the caller's dict does not carry the pinned key at all, so the strip found nothing to remove; the merge then re-imported the stale user value straight off disk and persisted it. No "managed setting(s) were not saved" notice was printed either, because nothing was stripped. Move the strip to just after the merge, so it sees the merged document. The `config set` single-key hard-reject path is unchanged. Impact: P3. While the managed pin is in force the runtime overlay still wins, so the effective configuration is correct and no user sees a wrong value today. The defect is that the stale user value is silently persisted to `~/.hermes/config.yaml` and would take effect if the administrator later removes the pin — a delayed, silent divergence rather than a live one. This does not touch NousResearch#62232's territory: the merge/CAS semantics of the atomic save seam are untouched. The change only reorders the managed-leaf strip relative to `_merge_partial_save`, both of which run before the write. Test: tests/hermes_cli/test_managed_scope_writeguard.py gains `test_bulk_merge_save_does_not_restore_existing_managed_leaf`, which pins `model.default` via the managed layer, seeds a user config carrying a stale `model.default` plus an unrelated section, runs a partial save, and asserts the pinned leaf is gone while the unrelated section and the new key survive. It also asserts the notice names the stripped key, which only fires if the strip runs after the merge. Receipts (tests/hermes_cli/test_managed_scope_writeguard.py): fail-before: 1 failed, 2 passed AssertionError: assert ('model' not in {'timezone': 'Asia/Bangkok', 'model': {'default': 'stale/user-model'}, 'x_unknown': {'keep': True}, 'agent': {}} or 'default' not in {'default': 'stale/user-model'}) pass-after: 3 passed Full tests/hermes_cli/: 6193 passed, 83 skipped, 1 failed — the one failure is test_service_manager.py::test_seed_supervise_skeleton_creates_expected_layout, which fails identically at the parent commit (macOS does not preserve the setgid bit the assertion expects) and is unrelated to this change. (cherry picked from commit a51fbb9)
Open
14 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The defect
Any code path that loads config into memory, holds it, and later calls
hermes_cli.config.save_config(cfg)silently reverts everyconfig.yamledit madein between by anyone else — another process, a human hand-edit, a second gateway.
save_configreserializes the caller's whole dict; its preservation logic(
explicit_raw_paths→_strip_default_values(..., preserve_keys=...)) can only KEEPpaths already present in that dict, never resurrect a path that exists on current disk
but not in the caller's stale copy.
_CONFIG_LOCKis thread-level only, socross-process writers race freely.
123c6f3a2addedatomic_config_writeas the fail-closed single chokepoint forconfig writes (unreadable-file guard), and
fe25806a6(#60591) added last-known-goodretention when the file fails to parse — both harden this exact seam against
corruption-shaped loss. This PR extends the same seam against concurrency-shaped
loss, and routes
save_configthroughatomic_config_writeinstead of its directatomic_yaml_writecall while at it.Real-world impact (how we hit it, repeatedly)
Multi-writer setup: several agent sessions + four long-running gateways sharing
profile configs.
privacy-relevant
agentmail.suppress_outbound_emailkey added on disk (andcommitted to git) in the interim. The platform fails closed on the missing key —
outbound email silently disabled until the next restart. Only keys added AFTER the
stale reader's load are lost, which makes the failure look selective and mysterious.
config.yamlfiles carrieduncommitted gateway-reserialization churn: comments stripped (including a
4-line reviewed rationale block), a deliberately pinned
auxiliary_models.compressionslot wiped back to auto, and asession_resetmode flip riding a save of unrelated keys. Cleaning it up cost a multi-slice
remediation batch.
reserialization emitted 78 trailing-whitespace lines in one profile config (0 at
git HEAD) — our commit gate (
git diff --check) then hard-blocked the snapshotcommit until the file was whitespace-normalized with parse-equality proof. And
because gateways run under launchd
KeepAlive(one respawned mid-procedure duringthis very remediation), ANY manual config edit currently requires a full
bootout/edit/bootstrap cycle to be safe from a concurrent rewrite. The write path
itself has to stop reverting what it didn't change; procedures can't.
This class of blind whole-state overwrite is also what keeps un-fixing our other
fixes (e.g. a dependency-pin repair orphaned by an unrelated reset on 2026-07-09 took
every profile's memory daemon down for hours) — hence the insistence on fixing the
writer, not adding another healer.
The fix
Inside
save_config's existing lock, after the normal pipeline (normalize →${ENV}-template restore → default-strip):(
_DISK_STATE_OBSERVATIONS: the last 8 DISTINCT on-disk states this processparsed via
read_raw_config/load_config, or wrote itself). Per key path:in-process sequential saves stay last-write-wins (a process may overwrite its
own writes, even with older values — e.g.
fallback_add's restore-the-primaryflow). "Authored" = changed relative to the disk state that write replaced;
values a save merely carried through or merge-preserved don't count, so a
foreign edit can't be laundered into "ours" by an intermediate save;
while disk now differs → stale snapshot: the disk-side edit wins — foreign
additions, value changes, and deletions the caller never saw all survive.
Logged at WARNING with the preserved key paths;
ConfigWriteConflictError: loud, logged,fail-closed, nothing written. Message names key paths only — config values can
be secrets.
ConfigDuplicateKeyError(write refused).YAML is last-key-wins, so a file with duplicates is semantically ambiguous —
usually a human mid-edit; rewriting it would silently collapse a stanza.
DEFAULT_CONFIG-defined subtree warns only when it near-matches a known orsibling key (
difflib, cutoff 0.8) — the shape of a real incident keydisplay.tool_progress_gr―ouping. Everything else stays silent: DEFAULT_CONFIGis NOT a complete schema (live configs legitimately carry
session_reset,plugins,mcp_serversat top level and 15–25 un-enumerated second-level keyslike
agent.verbose). Calibration receipt: against our five live estate configsthis warns on exactly 0 keys, while the historical garble would have matched.
atomic_config_write(the blessed chokepoint), and thepost-write state is recorded as a self-written observation.
The history-based base matters: a naive "merge against the last observation" is
defeated by any unrelated in-process
load_config()that runs after the foreign edit(long-running gateways reload constantly) — it absorbs the edit into the base and
launders the stale clobber. Test
test_foreign_edit_survives_even_after_intervening_loadpins this.Relation to the two options floated in the original issue draft: this is (a)+(b)
hybridized without signature churn — merge-preserve generalized to ALL keys (not just
ex-schema ones) plus CAS-style refusal for true conflicts. The draft's
removals:escape-hatch turned out unnecessary: delete-by-omission callers (migration pops,
_remove_mcp_server) act on a fresh snapshot over a quiet disk, which the mergeresolves as caller-wins; the MCP and migration suites pass unchanged.
Preserved behaviors (tested)
${ENV}template preservation (_preserve_env_ref_templates) — templates stillland on disk, expanded secrets still don't, including through a two-writer merge.
save(load())round-trip writes only user-authored keys.require_readable_config_before_writefail-closed guard (now double-covered viaatomic_config_write).fe25806a6adjacency: when the on-disk YAML is unparseable the disk state isUNKNOWN — no merge, no spurious conflict; save keeps its legacy overwrite (the
corrupt content was already
.bak-snapshotted by_warn_config_parse_failure,and load-side last-known-good retention is untouched). The corrupt-file test pins
this.
_LAST_EXPANDED_CONFIG_BY_PATH(the LKG source) now also folds inmerge-preserved disk values so a later parse-failure fallback doesn't serve them
stale.
Known limitations (deliberate)
behavior is unchanged from today (caller wins). Every real caller loads first.
locked (no flock); it shrinks from "caller's entire hold time" (minutes–hours in a
gateway) to milliseconds.
foreign addition it once observed gets the deletion suppressed WITH a WARNING
naming the path. We chose visible suppression over silent loss — silent loss is
the defect.
Tests
tests/hermes_cli/test_config_save_lost_update.py— 18 tests: two-writer hand-editsurvival (the repro — fails on unpatched main), foreign value-change survival,
intervening-load survival, foreign-deletion non-resurrection, in-process
last-write-wins, anti-laundering, caller deletion on quiet disk, CAS refusal (+
paths-not-values message), dup-key rejection, garble WARN + benign-key silence +
top-level silence, env-template round-trip, default-strip round-trip, corrupt-disk
legacy path, absent-file create.
Full sweep of every
save_config-touching test file (56 files, 2190 tests): 0failures. Verified on
main(a9f3f0870) and cherry-pick-clean onto our productioncheckout.
Environment
hermes-agent main @
a9f3f0870(2026-07-10), macOS, Python 3.11 venv.