township: loading a template was destroying the rest of the config (v0.2.49)

The 401 on video LoRA training was not a missing key. It was a DELETED one.

Loading a template persisted by writing the template over township_config.json:

    _j.dump(tcfg, f, indent=2, sort_keys=True)

A template is a PARTIAL — the LongCat starter carries 10 options, the config
carries 60. So loading it turned the operator's config into those 10 keys and
destroyed everything else, api_key and base_url included. Every subsequent call
to CoderAI then failed with a bare 401 that said nothing about why.

Found by comparing the live file against its own backups: township_config.json
was 238 bytes with exactly the LongCat starter's 10 keys, timestamped during this
session; every backup beside it was ~1700 bytes with 60 keys and a 75-character
api_key. The token itself was never gone — it is still 'fighters' in auth.json.

It now reads the existing config, updates it with the template, and writes the
result. A template's values win; nothing it is silent about is touched. With no
readable config yet, the template is the config, as before.

This is mine, from 310ffc79 ("loading a template persists, not just applies") in
v0.2.39. The persistence was right; replacing rather than merging was not.

The operator's config has been restored in place from the newest full backup with
the LongCat values kept on top: 60 keys, api_key present again.

Two tests: that a merge keeps what the template omits, and that the handler no
longer dumps the template over the file.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182DhR1eFNPQyGmDrpHbedY
parent 6c3a66f8
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
# Canonical product version for CoderAI — single source of truth. Both the API # Canonical product version for CoderAI — single source of truth. Both the API
# metadata and the admin web UI read from here. # metadata and the admin web UI read from here.
__version__ = "0.2.48" __version__ = "0.2.49"
__tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference." __tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference."
# Configure the CUDA caching allocator BEFORE torch is imported anywhere. # Configure the CUDA caching allocator BEFORE torch is imported anywhere.
......
...@@ -326,3 +326,43 @@ def test_the_starters_leave_the_image_side_stages_alone(): ...@@ -326,3 +326,43 @@ def test_the_starters_leave_the_image_side_stages_alone():
for name, cfg in TW.BUILTIN_TEMPLATES.items(): for name, cfg in TW.BUILTIN_TEMPLATES.items():
assert not (set(cfg) & image_side), name assert not (set(cfg) & image_side), name
assert cfg["keyframe_size"] == cfg["video_size"], name assert cfg["keyframe_size"] == cfg["video_size"], name
def test_loading_a_template_merges_into_the_config_instead_of_replacing_it(tmp_path):
"""A template is a PARTIAL. Writing it over the config destroyed every setting
it does not mention.
This is not hypothetical: loading the LongCat starter turned a 60-key
township_config.json into its own 10 keys, taking api_key and base_url with it
— after which every call to CoderAI came back 401 with nothing to explain why.
"""
cfg = tmp_path / "township_config.json"
cfg.write_text(json.dumps({
"api_key": "the-fighters-token", "base_url": "http://127.0.0.1:8776",
"image_model": "some/image-model", "matches": 6, "fps": 8,
"clip_min_frames": 50,
}))
template = {"video_model": "longcat", "fps": 15, "clip_min_frames": 173}
existing = json.loads(cfg.read_text())
merged = dict(existing)
merged.update(template)
cfg.write_text(json.dumps(merged))
got = json.loads(cfg.read_text())
# the template's values won…
assert got["video_model"] == "longcat"
assert got["fps"] == 15 and got["clip_min_frames"] == 173
# …and nothing it was silent about was lost.
assert got["api_key"] == "the-fighters-token"
assert got["base_url"] == "http://127.0.0.1:8776"
assert got["image_model"] == "some/image-model"
assert got["matches"] == 6
def test_the_load_handler_merges_rather_than_dumping_the_template():
"""Guards the actual code path: the bug was a bare json.dump(tcfg, ...)."""
src = (Path(__file__).resolve().parents[1] / "tools"
/ "gen_township_fighters.py").read_text(encoding="utf-8")
assert "_j.dump(tcfg, f" not in src, "the template is being written over the config"
assert "_merged.update(tcfg)" in src
...@@ -11864,8 +11864,22 @@ async function resetPrompts(ev){ ...@@ -11864,8 +11864,22 @@ async function resetPrompts(ev){
try: try:
_ap = Path(_active) _ap = Path(_active)
_ap.parent.mkdir(parents=True, exist_ok=True) _ap.parent.mkdir(parents=True, exist_ok=True)
# MERGE into the existing config — never replace it. A template is
# a partial: the LongCat starter carries 10 options, the config
# carries ~60. Writing the template alone silently destroyed every
# setting it does not mention, api_key and base_url included, and
# the next request then failed with a bare 401.
_merged = {}
try:
with open(_ap, encoding="utf-8") as _fh:
_existing = _j.load(_fh)
if isinstance(_existing, dict):
_merged.update(_existing)
except (OSError, ValueError):
pass # no readable config yet: the template IS the config
_merged.update(tcfg)
with open(_ap, "w", encoding="utf-8") as f: with open(_ap, "w", encoding="utf-8") as f:
_j.dump(tcfg, f, indent=2, sort_keys=True) _j.dump(_merged, f, indent=2, sort_keys=True)
f.write("\n") f.write("\n")
_persisted = str(_ap) _persisted = str(_ap)
except Exception as e: except Exception as 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