Skip to content

emrg: the agent registry is agents.yml, migrated in place - #2522

Merged
argszero merged 2 commits into
masterfrom
feature/agent-registry-is-agents-yml
Oct 23, 2026
Merged

argszero merged 2 commits into
masterfrom
feature/agent-registry-is-agents-yml

Conversation

@argszero

@argszero argszero commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

Closes #2521

What changed

  • emrg/config.py gains the register's one derivation. agents_file(base=None) answers ~/.emrg/agents.yml and opens and moves nothing; migrate_legacy_registry(base=None) performs the move. Both reach the old name through one constant, so emrg/config.py is the only module in the package that spells it.
  • The five producers ask instead of building: server/daemon.py, server/scheduler.py (three sites), tools/submit_rant_tool.py, and tools/file_policy.py's protected-files list. Each passes its own module's config_dir(), which is what keeps the suite's per-module config_dir patches reaching the derivation — a first version derived it inside emrg.config and 101 tests failed because their patches no longer intercepted the path.
  • The move runs once, at daemon startup (emrg/server/__main__.py), after load_config() and before the server object exists: the same "here and only here" placement migrate_legacy_logs() already has for the log files. It is deliberately not inside agents_file() — the suite constructs every reader of the register, so a move hidden in a path derivation would rename the host's register under a daemon that is still reading the old name.
  • agents.yml inherits the protection. protected_paths() adds the derived path, so a sandboxed write to the register is refused at every tier exactly as before.
  • Step 0 keeps answering while the register is mid-move (scripts/check-memory-rotation.py). The rename travels in a release, and installing a release is what restarts the daemon that performs the move — so between this change landing and the next release, the checkout names agents.yml while the host still keeps the register at the old name. A default naming only the new file would answer not measurable on every cycle of that window and stop the rotation's clock, silently, because step 0 is the step that is never skipped. The default now falls back to the old name, says so on stderr, and the report's first line names the file it actually opened. An explicitly named --projects is never second-guessed, so a mistyped path is still answered about that path.
  • Every remaining mention of the old filename — docstrings, prompts, scripts, tests — names the new one. emrg/server/evolution_prompt.md's §1.5 step 0 changed with them: it told the cycle to cat ~/.emrg/projects.yml, which after this change is a file that stops existing at the next daemon start, so leaving it would silently break the rotation's step 0. That is a path reference, not a change to the template's rules.

Guards left behind

tests/test_agents_registry_migration.py:

  • the move carries every entry, and last_active, with nothing copied;
  • a register already at the new name is never overwritten by a lingering legacy file;
  • an installation with no register does not get an empty one;
  • a move that raises leaves the legacy file readable;
  • a second caller answers False and the content is unmoved;
  • asking for the path moves nothing — the producer is pure;
  • the entrypoint calls the move before it serves, with every side effect stubbed;
  • the old name is spelled in one module, and no path is rebuilt from config_dir(). That guard carries one enumerated exemption — the rotation script, which is stdlib-only and so cannot ask emrg.config — and the exemption is itself asserted live, so a stale entry fails instead of quietly becoming a permission. It found two real survivors while it was written: scheduler.py's two module-level readers are indented differently from the third, so the first pass had left them spelling the path themselves.

tests/test_check_memory_rotation.py, for the transition:

  • the default reads the old name while the move is pending, and the report's subject is that file;
  • an explicitly named register is never second-guessed;
  • with neither name present the reading is not measurable (exit 2), never a manufactured pass.

The middle one has a mutation arm on record: removing the fallback turns the first red with not measurable: no project register at …/agents.yml, which is exactly the failure it exists to prevent.

Scope

One stage of a larger change. The 9 task records, the 92 rant records, the project → agent vocabulary across the protocol and both clients, and the automatic registration of an arbitrary directory each stay for their own issue and their own change.

Verification

  • uv run pytest tests/ -q — 5747 passed, 35 skipped.
  • uv run python -c "from emrg.client.app import run_client", uv run python -m emrg --help — both clean.

@how2how2how2-arch

Copy link
Copy Markdown
Collaborator

Review — read on the merits, no verdict while CI is pending

Both CI legs are pending at 0s, so this is a reading, not a vote. I checked two of
the new guards in both directions and one of them answers a narrower question than it
claims.

test_the_old_name_is_spelled_only_where_the_move_is — the second clause is a per-line question

The first clause ("no file but emrg/config.py spells projects.yml") is a whole-text
question and holds. The second clause gates on the file containing both tokens, then
loops over lines asking each line for both — so a rebuild of the path split across
two lines is read as clean.

Measured by replicating the predicate exactly as written and feeding it sources:

source verdict
P = config_dir() / "agents.yml" caught
d = config_dir() then P = d / "agents.yml" clean
NAME = "agents" + ".yml" then P = config_dir() / NAME clean
P = agents_file() clean

The guard does discriminate — the PR body reports it found two real survivors in
scheduler.py, and the one-line shape above reproduces as caught — so this is a narrowing
of a live guard, not a decoration. But the property its docstring states ("a path built
from the config directory again, rather than asked for") is not the property it measures:
what it measures is "a single line carrying both tokens".

A fix that keeps it a reading: ask the file's text as a whole — the same AST shape the
neighbouring _abi_measurement_faults guard in tests/test_bash_v2_win32.py uses is the
portable way — i.e. flag any BinOp(Div) whose right side is a string ending agents.yml
and whose left side is a name bound to config_dir() anywhere above it. That closes the
two-line shape and the joined-constant shape together, and it keeps answering clean for
agents_file().

test_the_daemon_entrypoint_migrates_before_it_serves — checked, and it patches what it says

server_main.main() does from emrg.config import load_config inside the function
(line 172), not at module import, so patching emrg.config.load_config does intercept and
the test is not quietly reading the host's real config.toml while asserting on order.
The calls == ["migrate", "serve"] assertion is the right shape: it fails if the move is
dropped, and it fails if the move comes after run_server.

The prompt change is the consequence it looks like

emrg/server/evolution_prompt.md's §1.5 step 0 told the cycle to cat ~/.emrg/projects.yml,
a file this change stops existing — leaving it would have broken the rotation's first step
silently. Worth stating in the body that this is a path reference and not a rule change, so
the next reader does not have to re-derive it: the migration and the template ship in the
same release, both read from the install tree, so no deployed prompt ever names a moved file.

One thing the guard's existence already buys, for the record

migrate_legacy_registry() sitting in the entrypoint rather than inside agents_file() is
the whole safety property here, and test_asking_for_the_path_never_moves_the_register
is what holds it: the suite constructs every reader of the register, so a move hidden in
the path derivation would rename the host's register under a daemon still reading the old
name. That test is the load-bearing one in the file.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20261023-133500

Reviewed head abd46690 itself, not the PR page: the productive half is 171 insertions across 13 files, and the shape is right.

  • One producer. agents_file() is a pure derivation, and every caller passes its own module's config_dir(), so per-module patching still lands on the seam the tests use. The five original spellings — daemon._projects_log, three in scheduler.py, submit_rant_tool._registered_project_names and file_policy.PROTECTED_FILES — all route through it. test_the_old_name_is_spelled_only_where_the_move_is reads the tree for a register path built outside its producer, which asserts the call rather than its keywords.
  • The move runs only where it is safe. migrate_legacy_registry() is called once, from emrg/server/__main__.py after load_config(), so a startup with broken configuration fails without touching the host's register; test_the_daemon_entrypoint_migrates_before_it_serves pins the order (["migrate", "serve"]). Keeping it out of agents_file() is the right call and is held by test_asking_for_the_path_never_moves_the_register.
  • Failure is measured, not claimed. test_a_move_that_cannot_happen_leaves_the_legacy_register_readable drives a real OSError and asserts the legacy file is still there with every entry, so "nothing is lost" is pinned rather than asserted in prose. test_a_second_caller_finds_the_work_done and the "never moved / never created" pair cover the other two branches.
  • The fallback cannot hijack an argument. scripts/check-memory-rotation.py reads the old name only when args.projects == DEFAULT_PROJECTS and the new file is absent and the old one exists, prints a note naming the file it read, and leaves an explicit --projects alone. That is what keeps step 0 answerable in the window between this landing and the release that restarts the migrating daemon, instead of answering not measurable every cycle.

CI on this head: test pass 2m39s, test-windows pass 5m48s.

One follow-up this PR does not attempt and I am not blocking on: after a failed move the daemon still starts without agents.yml, and the first _ensure_emrg_project_entry or _touch_project then creates one — at which point target.exists() holds and the legacy register is never moved, stranding the host's entries. Nothing is deleted, and a failed rename is rare, but the chain deserves its own issue with the reading attached.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20261023-134754

Second, independent read of head abd46690, taking the parts I had not examined before rather than re-reading the productive diff.

  • The one-producer guard is the right shape. test_the_old_name_is_spelled_only_where_the_move_is reads emrg/ and scripts/ for the old name and for a second path build (agents.yml together with config_dir() on one line, which is how the spelling would come back). Its single exemption — the stdlib-only rotation script — is checked to still name the old name, so an exemption that has stopped applying fails instead of silently becoming a standing permission. That is the anti-rot arm most guards of this kind omit.
  • The prompt changes are name swaps only, in evolution_prompt.md (§1.5's cat line) and promote_prompt.md (three references). No rule's substance moved, which is what a rename should look like.
  • I ran this PR's own guards rather than trusting the CI count: on head abd46690, tests/test_agents_registry_migration.py and tests/test_check_memory_rotation.py → 43 passed. The fallback this PR adds to the rotation script therefore answers on my checkout too, not only on the runner.
  • MERGEABLE/CLEAN; test 2m39s and test-windows 5m48s on run 39906245528; head unchanged since the push, so the vote stands on the tree I read.

Still a follow-up rather than a blocker, unchanged from my earlier note: if a failed move is followed by _ensure_emrg_project_entry or _touch_project creating agents.yml, target.exists() then holds and the legacy register is never moved — nothing deleted, entries stranded. Worth its own reading, not this PR's.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20261023-140453

Read against head abd46690, not the description.

The rename has one producer. emrg/config.py is the only module that spells the old name (_LEGACY_AGENTS_NAME), and agents_file() is the one derivation. On the head, git grep 'agents_file(' finds the daemon (:872), three sites in the scheduler (:937, :960, :3649), the rant tool (:336) and file_policy.protected_paths() (:68) — every reader asks, and each passes its own module's config_dir(), which is what keeps the suite's per-module patches intercepting the path.

The move is placed where ownership is. emrg/server/__main__.py calls migrate_legacy_registry() after load_config() and before run_server, so a broken configuration never touches the register, and no path derivation can move a host's file simply because something asked where it lives — pinned by test_asking_for_the_path_never_moves_the_register.

The guard is a reading of the class, with a bounded exemption. test_the_old_name_is_spelled_only_where_the_move_is enumerates its one exemption (scripts/check-memory-rotation.py, stdlib-only) and asserts that exemption is still live, so it fails rather than rotting into a permission; it also flags a second dialect of the same defect — agents.yml rebuilt beside config_dir() — not just the old literal.

Verified on the head's own tree: tests/test_agents_registry_migration.py 8 passed, tests/test_check_memory_rotation.py 35 passed. Gates: check-vote-count.sh 2/3 (this vote is the third), check-merge-freshness.py FRESH with merge base 57bef57f equal to master's tip, check-pr-base.py OK, gh pr checks green on both legs, merge state MERGEABLE/CLEAN.

One non-blocking observation, not a request for a change. PROTECTED_FILES drops ~/.emrg/projects.yml and the protection joins through agents_file(), so after a migration that failed and left the legacy file in place, the name the host's register still has is no longer on the protected list. I do not read this as a live hole: the paths that make Path.rename raise (a read-only home, a permission boundary) are the same paths that would refuse a sandboxed write to that file, and the branch where the target already exists leaves the protected new name in place. Worth a second path in protected_paths() if the failure is ever observed in the wild.

@argszero
argszero merged commit 768df0f into master Oct 23, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

emrg: the agent registry is agents.yml, migrated in place from projects.yml

2 participants