fix(cli): make profile.yaml and skin writes atomic to stop silent field loss (supersedes #51808) - #78301
Conversation
…ld loss
`write_profile_meta` and `hermes skin set` are both read-modify-write
helpers that rewrite a user-visible YAML file with a bare truncating
write, bypassing `utils.atomic_yaml_write` — the shared helper whose
docstring states that "every destructive file rewrite in the codebase
shares one implementation".
Both read halves swallow a parse error and fall back to `{}`, so a
truncated file is not transient corruption. The next call reads `{}` and
silently, permanently drops every field the caller did not explicitly
pass:
* `write_profile_meta` promises "unspecified fields preserve existing
values". After an interrupted write, a follow-up call that only sets
`description_auto` erases the profile's `description` — it vanishes
from `hermes profile list` and never comes back.
* `_skin_set` exists so that "changing one token never disturbs the rest
of the look". `path.write_text(...)` neither fsyncs nor swaps
atomically, so a crash or power loss can leave `<skin>.yaml`
zero-length; the next tweak then rewrites from empty and the whole
palette is gone. The gateway's skin watcher repaints live surfaces
from this file within ~1s, so a half-written file is observable.
Routing both through `atomic_yaml_write` gives temp file + fsync +
`atomic_replace`, which also preserves a symlinked target (GitHub
NousResearch#16743) and restores owner/mode, and emits emoji descriptions as real
UTF-8 instead of `\UXXXXXXXX` escapes (GitHub NousResearch#51356).
Supersedes NousResearch#51808, which fixed the unicode-escaping symptom alone by
adding `allow_unicode=True` to the same `yaml.safe_dump` call.
There was a problem hiding this comment.
Pull request overview
This PR hardens two CLI “read-modify-write” YAML update paths (profile.yaml metadata and user skin files) by routing them through the shared utils.atomic_yaml_write() helper. This prevents interrupted/truncated writes from causing persistent user-visible YAML corruption or silent loss of fields on subsequent writes, while preserving symlinked targets.
Changes:
- Switch
hermes_cli/profiles.py:write_profile_meta()from truncatingopen(..., "w") + yaml.safe_dumptoatomic_yaml_write(...). - Switch
hermes_cli/skin_cmd.py:_skin_set()fromPath.write_text(yaml.safe_dump(...))toatomic_yaml_write(...). - Add regression tests covering durability, UTF-8 emission, and symlink preservation for both paths.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
hermes_cli/profiles.py |
Use atomic_yaml_write for profile metadata writes to prevent torn-write data loss and preserve merge semantics. |
hermes_cli/skin_cmd.py |
Use atomic_yaml_write for skin edits to make writes durable and atomic (including symlink preservation). |
tests/hermes_cli/test_profiles.py |
Add regression tests ensuring interrupted writes don’t corrupt/erase profile.yaml fields and UTF-8 remains unescaped. |
tests/hermes_cli/test_skin_cmd.py |
Add tests asserting skin set reaches disk durably and preserves symlinked skin files. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # leave <skin>.yaml zero-length — and the read above falls back to ``{}`` | ||
| # on a parse error, so the next ``hermes skin set`` rewrites from empty | ||
| # and permanently drops the rest of the palette. The gateway's skin | ||
| # watcher repaints live surfaces from this file within ~1s, so a | ||
| # half-written file is observable, not only a crash-window concern. |
There was a problem hiding this comment.
@copilot Correct, and fixed in commit 0a86a39dc (branch head 0a86a39dc) — comment only, no behavior change.
You are right that _skin_set has no try/except around yaml.safe_load, so invalid YAML raises and aborts the command rather than falling back. I had carried the wording over from write_profile_meta, which does wrap its read in try/except Exception: existing = {} — the two sites genuinely differ here and the comment flattened them.
The {} fallback in _skin_set comes only from safe_load("") returning None, which the or {} on line 52 converts to an empty dict. That is not a narrower case for this PR, though: a zero-length file is exactly what an unsynced, non-atomic write_text leaves behind after a crash or power loss, so the loss chain the change closes is unchanged. A file left containing invalid YAML raises — loud, and not silent data loss. The comment now says that explicitly, including the contrast.
I deliberately did not add fallback-on-parse-error handling, for the reason you name: swallowing a parse error there would be a behavioral change, and a bad one — it would convert a loud abort into the silent palette wipe this PR is trying to eliminate.
Verified by test_set_persists_the_skin_durably in tests/hermes_cli/test_skin_cmd.py:63 (asserts fsync is reached before _skin_set returns, and that background / banner_title survive the write). Fails on unmodified main, passes here. The same correction is now in the PR description.
…ead path
_skin_set has no try/except around yaml.safe_load, so invalid YAML raises
and aborts the command. The {} fallback comes only from safe_load()
returning None on a zero-length file — which is exactly the state a torn,
unsynced write leaves behind, so the data-loss chain is unchanged.
What does this PR do?
Two Channel-A CLI helpers rewrite a user-visible YAML file with a bare truncating write, bypassing
utils.atomic_yaml_write— the shared helper whose own docstring states the invariant:Both are read-modify-write helpers whose read half degrades an unreadable file to
{}. That is what turns a torn write from transient corruption into silent, permanent data loss: the next call reads{}and drops every field the caller did not explicitly pass. The two get there differently, and the distinction matters —write_profile_metawraps its read intry/except Exception: existing = {}, so it absorbs any corruption;_skin_sethas notry/except, so invalid YAML raises and aborts loudly, and only a zero-length file degrades silently (safe_load("") is None, thenor {}). Zero-length is precisely what an unsynced, non-atomic write leaves behind, so the loss chain holds for both.hermes_cli/profiles.py—write_profile_meta(sharpest). Its docstring promises "Only the explicitly passed fields are overwritten; unspecified fields preserve existing values."open(path, "w")truncates beforeyaml.safe_dumpruns, so a crash or Ctrl-C mid-write leavesprofile.yamlempty. The user retries; that call passes onlydescription_auto, the read falls back to{}— and the profile'sdescriptionis gone fromhermes profile listfor good, with no error surfaced. This is a direct violation of the function's documented merge contract.hermes_cli/skin_cmd.py—_skin_set. The module docstring saysset"changes ONE color of the ACTIVE skin in place, so tweaking (say) the tool marker never disturbs the rest of the look — background included."path.write_text(...)neitherfsyncs nor swaps atomically, so a crash or power loss can leave<skin>.yamlzero-length; the nexthermes skin setthen loads that asNone, falls back to{}, and the rest of the palette is permanently gone. The gateway's skin watcher repaints every live surface from this file within ~1s, so a half-written file is directly observable, not just a crash-window concern.Routing both through
atomic_yaml_writegives temp file +fsync+atomic_replace, which additionally preserves a symlinked target (GitHub #16743 — dotfile managers symlink these paths into a tracked repo), restores owner and mode, and emits emoji descriptions and kaomoji cursors as real UTF-8 instead of\UXXXXXXXXescapes (GitHub #51356).Supersedes #51808
@srojk34's #51808 (open since 2026-06-24, no reviews) targets the exact line this PR replaces, adding
allow_unicode=Trueto thatyaml.safe_dumpcall.atomic_yaml_writepassesallow_unicode=Trueinternally, so this change strictly subsumes it — it fixes the unicode escaping and the torn write.test_emoji_description_is_written_as_real_utf8covers #51808's case and is verified to fail on currentmain. The assertion lives in the existingtests/hermes_cli/test_profiles.pyrather than in a new file, so nothing collides with #51808's own test module if the maintainers prefer to land that one instead.Sibling-site sweep — every
yamlwrite site in Channel AEnumerated with
git ls-tree -r --name-only origin/main | grep -E '^(hermes_cli/|gateway/|tui_gateway/|cli\.py|utils\.py|hermes_state.*\.py)'piped throughgrep -n 'yaml.safe_dump\|yaml.dump\|write_text(yaml'. Six hits, all accounted for:hermes_cli/profiles.py:871open(w)+safe_dumponprofile.yamlhermes_cli/skin_cmd.py:76write_text(safe_dump(...))on~/.hermes/skins/<name>.yamlhermes_cli/xai_retirement.py:249hermes_cli/config.py:1185return yaml.safe_dump(value), a formatter returning a string; no file touchedhermes_cli/profile_distribution.py:257return yaml.safe_dump(data, ...); no file touchedutils.py:345atomic_yaml_write's implementationWhy
xai_retirement.py:249is not converted here. That site dumps aruamel.yamlround-trip document (YAML(typ="rt")), which exists precisely to preserve the user's comments and quoting inconfig.yaml.atomic_yaml_writeis PyYAML-based and raisesRepresenterError: cannot represent an objecton aCommentedMap— I verified this directly, so the "obvious" one-line conversion there would break the migration, not harden it. Making that path atomic needs a round-trip-aware helper (the pattern already inlined inutils.atomic_roundtrip_yaml_update), which is a separate change with its own blast radius. It is also the least exposed of the three: it is a one-shot migration, already guarded byrequire_readable_config_before_write, and takes ashutil.copy2backup to.bak-pre-migrate-xai-<ts>on the default path, so loss there is recoverable. Happy to follow up on it separately if you'd like it in the same series.Precedent on these exact files
mainalready carries a merged symlink-preservation and atomic-write series acrosshermes_cli/profiles.py—b6b9bcd2a(profile export),8d9684c9d(default-export paths, #58394),b7192b1cb(clone-all + skills clone) — plus #3800, #4320/#4298, #18217, #10618 and #16980 elsewhere. Those covered the clone/export paths; theprofile.yamlmetadata write was never converted. This ships the missed site of a series you already merged.Related Issue
No filed issue — found by sweeping the remaining destructive YAML write sites against
utils.atomic_yaml_write's stated single-implementation invariant.Type of Change
Changes Made
hermes_cli/profiles.py—write_profile_metanow callsatomic_yaml_write(path, existing, sort_keys=False, default_flow_style=False)instead ofopen(path, "w")+yaml.safe_dump. Same kwargs, same defaults.hermes_cli/skin_cmd.py—_skin_setnow callsatomic_yaml_write(path, data, sort_keys=False)instead ofpath.write_text(yaml.safe_dump(..., allow_unicode=True)). The explicitpath.parent.mkdir(...)is dropped because the helper does it;allow_unicode=Trueis set inside the helper, so the kaomoji cursors and box-drawingtool_prefixvalues still round-trip as UTF-8.tests/hermes_cli/test_profiles.py— newTestWriteProfileMetaDurability(4 tests), inserted mid-file betweenTestInternalHelpersandTestEdgeCasesrather than appended at EOF, to avoid colliding with the several open PRs that append there.tests/hermes_cli/test_skin_cmd.py— 2 new tests.One behavioral note: the helper serializes with
IndentDumper, so nested sequences gain 2-space indentation. That is the repo's canonical layout (#31999, aligning PyYAML with theruamelwriters); neither file's schema currently contains a list (profile.yamlisdescription/description_auto; skins are flatstr -> strmaps plustool_prefix), so no existing file's bytes change.How to Test
Fail-before / pass-after was verified in both directions by stashing only the two production hunks and re-running the new tests against unmodified
main:maintest_failed_write_leaves_existing_file_intactprofile.yamlis already truncated when the dump raisestest_failed_write_does_not_silently_drop_unspecified_fieldsassert '' == 'Curated by hand'test_emoji_description_is_written_as_real_utf8description: "Code wizard \U0001F9D9 ✨"test_set_persists_the_skin_durablyfsyncbeforeskin setreturnstest_symlinked_profile_yaml_survives_the_writetest_set_preserves_a_symlinked_skin_fileThe last two are stated as conversion guards, not fixes:
open(w)/write_textalready write through a symlink, so symlink survival is an invariant this change must not regress — a naiveos.replaceconversion would detach the link, which is exactly whatatomic_replaceprevents (#16743). They are included because that regression is the main hazard of this kind of change, and they pass before and after by design.The two durability tests break
yaml.safe_dumpandyaml.dumpso the failure is serializer-agnostic — the old code used the former, the helper uses the latter — which keeps them measuring durability rather than the choice of entry point. They use a scopedpytest.MonkeyPatch.context()rather than themonkeypatchfixture so the patch is reverted mid-test without disturbing the session-wide env isolation that shares the function-scoped instance.Manual check:
hermes profile create demo && hermes profile describe demo --description "Curated by hand"printf '' > ~/.hermes/profiles/demo/profile.yaml(simulates the post-crash state)mainthe description is gone fromhermes profile list; the point of this PR is that step 2 can no longer be reached by an interrupted write.Adjacent suites run green:
tests/hermes_cli/test_profile_describer.py,tests/hermes_cli/test_skin_engine.py,tests/hermes_cli/test_skin_palettes.py,tests/cli/test_cli_skin_integration.py,tests/test_cli_skin_integration.py— 102 passed together with the two touched files.Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests pass — ran the focused + adjacent suites listed above (102 passed), not the full suiteDocumentation & Housekeeping
docs/, docstrings) — or N/A (no user-facing behavior or docstring contract changed; the contract is now actually honored)cli-config.yaml.exampleif I added/changed config keys — or N/ACONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — or N/Aatomic_replaceis the repo's existing cross-platform primitive and already hasEXDEV/EBUSYfallbacks; the two symlink guard tests will skip or fail on a Windows runner without developer mode, so please flag if CI shows that and I'll gate them onos.symlinkavailabilityRelated / Positioning
Checked against the open queue two ways, because the two nets are blind in opposite directions:
gh pr list --json files, 800 most recent open PRs, spanning feat(cli): prompt to stop running hermes.exe before Windows update #76714–fix(acp): support per-session reasoning effort for thinking control (#78229) #78295): zero rivals onhermes_cli/skin_cmd.pyortests/hermes_cli/test_skin_cmd.py. Five PRs touchhermes_cli/profiles.pyor its test file (test: gate OS-specific tests by real host, add macOS + Windows CI lanes #77992, fix(buzz): latch DM classification on p-tag when channel metadata says DM #77901, fix(doctor): support local Mem0 and quoted profile aliases #77058, Dev/hermes upgrade t 16bbffad #76853, chore: upgrade carried Hermes runtime to v2026.7.30 (#4) #76791) — all hunk-disjoint fromwrite_profile_meta's write.gh search prs, all open):write_profile_metareturns exactly one PR — fix(profiles): add allow_unicode=True to write_profile_meta yaml.safe_dump #51808, superseded as described above.skin_cmd/_skin_setreturn zero. fix(cli): normalize profile description_auto values from profile.yaml #27708 edits thedescription_autocoercion two lines above this hunk; different bug, no overlap with the write itself.