From 9c21fa4e4fd6dbc0c503a72d25877247e316e078 Mon Sep 17 00:00:00 2001 From: Godopu Date: Sun, 25 Oct 2026 15:16:41 +0000 Subject: [PATCH] feat(registry): format version 2, migrated by the writer (Issue #71) - lib_py/registry_format.py: a pure, idempotent migrate() drops kill_command/start_command and rebuilds attach_command on every row (stopped/terminated included) and sets the top-level registry_format key. - atomic_dump_yaml migrates an older registry under its lock, before the caller's mutation, and then rewrites the YAML as well as the DB (the YAML write is otherwise skipped when only fields change). load_state_json stays read-only. New registries are written with the current version. - Entry point for #72: `python -m lib_py.registry_format --yaml ` runs the migration through the writer and prints `registry-format: -> , rows changed: `. Refs #71 Co-Authored-By: Claude Opus 5.5 --- .agents/skills/lib_py/agents/sanitize.py | 12 ++++ .agents/skills/lib_py/atomic_yaml.py | 14 +++- .agents/skills/lib_py/registry_format.py | 92 ++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 .agents/skills/lib_py/registry_format.py diff --git a/.agents/skills/lib_py/agents/sanitize.py b/.agents/skills/lib_py/agents/sanitize.py index 5b28304..1865e24 100644 --- a/.agents/skills/lib_py/agents/sanitize.py +++ b/.agents/skills/lib_py/agents/sanitize.py @@ -1,5 +1,6 @@ import hashlib import re +import shlex def sanitize_herdr_agent_name(name: str) -> str: """ @@ -29,3 +30,14 @@ def sanitize_herdr_agent_name(name: str) -> str: return s + +def attach_command(server, name): + """The registry row's attach_command (Issue #71). + + A native herdr command, so it works from any shell: the server is always + named (`default` too - herdr's env-free default), and the agent by the name + herdr registered (sanitize_herdr_agent_name). create, resume and reconcile + all write it from here. + """ + return "herdr --session %s agent attach %s" % ( + shlex.quote(server or "default"), shlex.quote(sanitize_herdr_agent_name(name))) diff --git a/.agents/skills/lib_py/atomic_yaml.py b/.agents/skills/lib_py/atomic_yaml.py index 32227bf..8e891ad 100644 --- a/.agents/skills/lib_py/atomic_yaml.py +++ b/.agents/skills/lib_py/atomic_yaml.py @@ -4,6 +4,7 @@ import os, sys, tempfile, shutil, glob, subprocess, json, sqlite3, fcntl from datetime import datetime, timezone import yaml +from lib_py import registry_format def atomic_dump_yaml_main(): yaml_path = os.environ['YAML_PATH'] @@ -114,6 +115,16 @@ def atomic_dump_yaml_main(): elif 'herdr_sessions' not in d: d['herdr_sessions'] = [] + # Issue #71: an older registry is migrated here, under the lock and + # before the caller's mutation. It changes row fields only, so the + # YAML is rewritten whether or not the mutation changes anything. + from_version = registry_format.stored_version(d) + migrated_rows = 0 + force_yaml = False + if from_version < registry_format.CURRENT: + d, migrated_rows = registry_format.migrate(d) + force_yaml = True + old_sessions = get_all_sessions_status(d) # a mutation that replaces a row the monitor registered for a pane it # created itself (create_session.sh) drops that name from old_roles @@ -167,7 +178,7 @@ def atomic_dump_yaml_main(): conn.commit() - if new_sessions != old_sessions: + if force_yaml or new_sessions != old_sessions: if os.path.exists(yaml_path): try: shutil.copy2(yaml_path, yaml_path + '.bak') @@ -207,6 +218,7 @@ def atomic_dump_yaml_main(): _flock_f.close() except Exception: pass + return from_version, registry_format.stored_version(d), migrated_rows if __name__ == '__main__': atomic_dump_yaml_main() diff --git a/.agents/skills/lib_py/registry_format.py b/.agents/skills/lib_py/registry_format.py new file mode 100644 index 0000000..c015230 --- /dev/null +++ b/.agents/skills/lib_py/registry_format.py @@ -0,0 +1,92 @@ +"""Registry format version and its migration (Issue #71). + +The session registry (agent-sessions.yaml + agent-sessions.db) carries a +top-level `registry_format` key; it lives in the DB `state` row like every +other top-level key. A registry without it is format 1. + + 1: rows may carry `kill_command` and `start_command`, and `attach_command` + reads `HERDR_SESSION_NAME= herdr agent attach `. + 2: no `kill_command` / `start_command`; `attach_command` is + lib_py.agents.sanitize.attach_command(, ). + +The writer (lib_py.atomic_yaml, the only code that writes the registry) +migrates an older registry under its lock before the caller's mutation, and +then rewrites the YAML as well as the DB. Readers (lib_py.state) never +migrate; they see the old rows until the first write. + +Entry point (for #72, the update skill): + + python -m lib_py.registry_format --yaml + +runs that migration through the writer (same lock, YAML rewritten), prints one +line `registry-format: -> , rows changed: ` and exits 0 when the +registry was migrated or already current, nonzero on error. Run it with the +skills directory on PYTHONPATH (as lib.sh does), e.g. +`PYTHONPATH=.agents/skills python3 -m lib_py.registry_format --yaml .mam/agent-sessions.yaml`. +""" + +import argparse +import copy +import os +import sys + +from lib_py.agents.sanitize import attach_command + +FORMAT_KEY = "registry_format" +CURRENT = 2 +_DROPPED = ("kill_command", "start_command") + + +def stored_version(d): + """The format a registry dict says it has (1 when it says nothing).""" + v = d.get(FORMAT_KEY) if isinstance(d, dict) else None + return v if isinstance(v, int) and not isinstance(v, bool) else 1 + + +def _row_server(s): + return s.get("herdr_session") or s.get("herdr_server") or "default" + + +def migrate(d): + """Return (new registry dict, rows changed). Pure and idempotent: `d` is + not modified, and migrating the result again changes nothing. Every row is + migrated, `stopped` and `terminated` ones included.""" + out = copy.deepcopy(d) if isinstance(d, dict) else {} + changed = 0 + for s in out.get("herdr_sessions") or []: + if not isinstance(s, dict) or not s.get("name"): + continue + before = dict(s) + for k in _DROPPED: + s.pop(k, None) + s["attach_command"] = attach_command(_row_server(s), s["name"]) + if s != before: + changed += 1 + out[FORMAT_KEY] = CURRENT + return out, changed + + +def main(argv=None): + ap = argparse.ArgumentParser(prog="python -m lib_py.registry_format", + description="Migrate the session registry to the current format.") + ap.add_argument("--yaml", required=True, help="path of agent-sessions.yaml") + a = ap.parse_args(argv) + yaml_path = os.path.abspath(a.yaml) + db_path = os.path.splitext(yaml_path)[0] + ".db" + if not (os.path.exists(yaml_path) or os.path.exists(db_path)): + print("registry-format: error: no registry at %s" % yaml_path, file=sys.stderr) + return 1 + from lib_py.atomic_yaml import atomic_dump_yaml_main + os.environ["YAML_PATH"] = yaml_path + os.environ["AGENT_SESSIONS_MUTATION"] = "pass" + try: + frm, to, n = atomic_dump_yaml_main() + except (Exception, SystemExit) as exc: # the writer refuses with SystemExit + print("registry-format: error: %s" % (exc,), file=sys.stderr) + return 1 + print("registry-format: %d -> %d, rows changed: %d" % (frm, to, n), flush=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main())