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())