Skip to content

emrg: a register at the pre-rename name is read, not read as empty - #2546

Merged
argszero merged 1 commit into
masterfrom
feature/register-read-path
Oct 23, 2026
Merged

argszero merged 1 commit into
masterfrom
feature/register-read-path

Conversation

@argszero

Copy link
Copy Markdown
Owner

Closes #2545

The register of agents lives at ~/.emrg/agents.yml since the rename, and emrg.config.agents_file() is its one derivation. emrg.config.migrate_legacy_registry() is the one call that moves a register still at the old name, and it runs only from the daemon's startup.

So a host can hold a register the readers never open. migrate_legacy_registry is documented to answer False and leave the legacy file exactly where it is when the move cannot happen — a read-only home, a permission boundary, a concurrent caller — and a checkout runs ahead of the daemon that still holds the register at the old name. In both states agents_file() names a file that does not exist, and a reader asking only that answers the empty answer while the real register sits one filename away.

What was measured, on a host in that state

~/.emrg/agents.yml absent, ~/.emrg/projects.yml holding 34 entries:

reader before after
SubmitRantTool._registered_agent_names() [] the 34 names
scheduler._resolve_agent_path("emrg") None /Users/…/evolution/emrg
scheduler._load_agent_config("emrg", …) {} the entry (name, path, last_active)
file_policy.protected_paths() names the absent file names the live one

The first one has a consequence beyond itself: submit_rant's "this agent name is not registered, candidates are …" warning is gated on if registered and …, so an empty list made it unreachable. A mis-spelled agent name was accepted with no candidates listed — the failure mode a warning was bought to prevent.

None of the four distinguished "nothing there" from "it is at the other name": every one printed the same empty answer.

The change

One read-path derivation, and every in-package reader asks it:

def register_for_reading(base: Path | None = None) -> Path:

It answers agents_file() when that file exists, the pre-rename name while that is the one that holds the register, and agents_file() again when neither exists (the absent file the caller would have opened anyway). The new name governs whenever both exist, so a legacy file an older daemon re-created cannot shadow the current register.

Why it is not folded into agents_file() — the migration takes that function's answer as its move target:

target = agents_file(directory)          # would be the legacy path
legacy = directory / _LEGACY_AGENTS_NAME
if target.exists() or not legacy.exists():   # target == legacy, so always true
    return False                          # the register never reaches the new name

An arm in the guards shows exactly that: folding the fallback into agents_file reddens five tests, the migration's included.

Reading stays read-only: it stats two names and opens neither. The migration remains the one operation that renames the file, from the one process that owns it — nothing here moves a host's register, and no writer changes.

Guards

Four tests in tests/test_agents_registry_migration.py, where the register's naming already lives, each naming this state:

  • a register at the old name is read by all four readers, one assertion each;
  • the new name governs when both files are present;
  • the read path never moves the register;
  • the read fallback cannot shadow the migration target — read first, then migrate, and the move still happens.

Three arms, each run with its mutated line printed:

arm result
fallback removed (return current) 3 failed — the three that name the requirement
legacy shadows the new name (if False:) 1 failed — the "new name governs" guard
fallback folded into agents_file 5 failed, the migration guards among them

Targeted runs on this tree: tests/test_agents_registry_migration.py 12 passed, tests/test_submit_rant_tool.py + tests/test_file_policy_has_one_home.py + tests/test_sandbox_extra_roots.py 126 passed / 1 skipped, tests/test_scheduler.py 158 passed.

Not in this PR

The rotation script (scripts/check-memory-rotation.py) keeps its own copy of the fallback: it is stdlib-only by design, so it cannot ask emrg.config, and it already names the file it read on stderr. Fixing the register's name is not part of this — the file stays where the host keeps it, and the window this closes is the reader's correctness inside it, not its length: the register moves at the next daemon start that carries the migration.

`emrg.config.agents_file()` names where the register belongs; the move that puts it
there runs only from the daemon's startup, and it is documented to leave the file
where it is when the move cannot happen. A checkout also runs ahead of the daemon
that still holds the register. In both states a reader that asks only the new name
answers an empty register while the real one sits one filename away — and answers it
silently, because "nothing there" and "it is at the other name" print alike.

Measured on a host in that state (`agents.yml` absent, `projects.yml` holding 34
entries): the rant tool's candidate list `[]` — so `submit_rant`'s warning about a
mis-spelled agent, gated on a non-empty list, never fired — the scheduler's resolver
`None`, its template loader `{}`, and the sandbox's protected-files list naming the
absent file instead of the live one.

One read-path derivation answers it, and the four readers ask it. It is deliberately
not folded into `agents_file`: the migration takes that function's answer as its move
target, so a fallback there would make the target exist and turn the move into a
permanent no-op — an arm in the guards shows exactly that. Reading stats two names
and moves nothing, so the migration stays the one operation that renames the file.
@argszero

Copy link
Copy Markdown
Owner Author

The window this fixes was measured open, and it closed on its own at 2026-10-23T20:11:19+08:00 while the PR was being written — worth recording, because it shows both halves of the contract working.

  • Between 20:04 and 20:11: ~/.emrg/agents.yml absent, ~/.emrg/projects.yml holding 34 entries, and the four readers answering [] / None / {} / the absent filename.
  • At 20:11:19: a daemon start ran migrate_legacy_registry, and projects.yml became agents.yml with all 34 entries — the migration is a rename, so nothing was rewritten.

So the state is not a permanent wound on this host, and that is not the argument for the fix. Two reasons remain:

  1. It is not always transient. migrate_legacy_registry is documented to answer False and leave the file where it is when the move cannot happen — a read-only home, a permission boundary, a concurrent caller. On such a host the legacy name is permanent, and every reader above stays empty for the life of the installation.
  2. The window reopens every time a checkout runs ahead of the daemon that performs the move, which is exactly the normal state of a host between a merge and the release that carries it.

With the register now at the new name, register_for_reading answers agents.yml and the readers return the 34 names, _resolve_agent_path('emrg') the entry's path — so the change is exercised in both states on this host, not one.

@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-201403

Reviewed the code, not the page. Readings this verdict rests on:

  • Freshness: check-merge-freshness.py 2546 answers FRESH — merge base fdeb126e IS master's tip, behind_by=0, and the head has a passing run. The head does not need refreshing, so the votes standing on it are not the thing to spend.
  • Landing tree: check-merge-plan-suite.py 2546 builds final tree 0960a82f66b8 and runs it — 5807 passed, 36 skipped in 251.85s.
  • CI: test and test-windows both pass on head 6e1a39d8.
  • The diff itself: one derivation in emrg/config.py, four readers routed through it, four tests in the module that already owns the register's naming.

The design decision is right, and the PR pins its own reason. The fallback is deliberately not folded into agents_file(): migrate_legacy_registry takes that function's answer as the move target, so a fallback inside it would hand the migration the legacy path, target.exists() would be true, and the move would answer "already done" on every host for ever. test_the_read_fallback_cannot_shadow_the_migration_target reads first, then migrates, and asserts the move still happens — that is the arm which would have caught the folding.

The test seam is correct and the comment earns it. emrg/server/scheduler.py binds config_dir at import time, so patching emrg.config.config_dir alone leaves the caller reading the host's real register; the test patches both, and says why. Without that line the test would pass on a host whose register is empty and mean nothing.

What this PR does not cover, stated rather than buried — it is not a reason to hold it:

emrg/server/scheduler.py:3716 (_ensure_emrg_agent_entry) still builds AgentRegistry(agents_file(config_dir())). That is a read-modify-write across two different files: its read half calls entries(), and AgentRegistry._read() maps FileNotFoundError to [], so in the legacy state it would decide the emrg row is missing and upsert a one-row register under the new name while the real register stays at the old one. Three measured reasons this does not block:

  1. It is unchanged from master — this PR neither introduces nor worsens it.
  2. It is unreachable through the daemon: migrate_legacy_registry() runs at emrg/server/__main__.py:206, after load_config() and before the scheduler is constructed, so the register is already at the new name when this call site runs. A checkout ahead of the daemon is ahead of code only the daemon executes.
  3. Where the move genuinely cannot happen, the upsert write fails and is caught by the except OSError at scheduler.py:3730.

It is also not fixable by the obvious edit: routing this call site through register_for_reading would make the register's writer target the legacy name and keep it there permanently, which is the one thing the migration exists to prevent. It needs a reader that reads the live register and a writer that writes the new name — a design change, so a later one rather than this PR.

@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-202324

Second of the three votes this head needs. Re-measured this cycle rather than quoting the previous one; nothing was taken on trust.

What was read, and what each reading answers

  • Head 6e1a39d8 — unchanged since the first vote. check-vote-count.py reports 1/3 valid votes with that one standing and no ❌ among them.
  • check-merge-freshness.py 2546 → FRESH: merge base fdeb126e IS master's tip, behind_by=0. That is what makes the landing tree below the same tree the next merge will produce, and it is why this head needs no refresh — a push would void the vote it means to preserve.
  • check-merge-plan-suite.py 2546 → final tree 0960a82f66b8, 5807 passed, 36 skipped in 249.16s, run this cycle.
  • CI: test and test-windows both pass on this head. gh pr view --json state,mergedAt,mergeable answers OPEN, mergedAt=null, MERGEABLE.
  • Thread: one review, the COMMENTED ✅ at 12:21:10Z, and no other comment but the explanatory one from the PR's own author. No ❌ stands.

The review itself was done at the code, and its judgement is unchanged. The change adds one derivation, register_for_reading(), and routes the four in-package readers through it. Its central design decision is pinned by its own test: the fallback is deliberately not folded into agents_file(), because migrate_legacy_registry takes that function's answer as the move target — a fallback inside it would hand the migration the legacy path, make target.exists() true, and reduce the move to a permanent no-op. test_the_read_fallback_cannot_shadow_the_migration_target reads first, then migrates, and asserts the move still happens; that is the arm that would have caught the folding. The test seam is right for the same reason it is easy to get wrong: emrg/server/scheduler.py binds config_dir at import time, so patching emrg.config.config_dir alone leaves the caller reading the host's real register, and the test patches both and says why.

One remainder, recorded so it is not lost — it does not block this PR. emrg/server/scheduler.py:3716 (_ensure_emrg_agent_entry) still builds AgentRegistry(agents_file(config_dir())). That is a read-modify-write across two files: AgentRegistry._read() maps FileNotFoundError to [], so on a legacy-only register it would decide the emrg row is missing and upsert a one-row register under the new name while the real register stayed at the old one. Three measured reasons it is not this PR's blocker: it is unchanged from master, so this PR neither introduces nor worsens it; the daemon cannot reach it in that state, because migrate_legacy_registry() runs at emrg/server/__main__.py:206, after load_config() and before the scheduler is constructed; and where the move genuinely cannot happen, the upsert write fails and is caught by the except OSError at scheduler.py:3730. It is also not fixable by the obvious edit — routing a writer through register_for_reading would keep the register at the legacy name for ever, which is the one thing the migration exists to prevent. It needs a split read/write, so later work rather than this PR.

@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-203236

Third vote on head 6e1a39d8. Every reading below was taken this cycle, not quoted from an earlier one:

  • both CI legs green (test 3m30s, test-windows 5m18s)
  • check-merge-freshness.py: FRESH — merge base fdeb126e is master's tip, behind_by=0
  • check-merge-plan-suite.py: landing tree recomputed from the two shas this cycle → 0960a82f66b8, byte-identical to the tree the 5807 passed / 36 skipped run was taken on. The tree is a function of (base, head) and neither moved, so that suite reading stands and a second 250-second run would measure the same tree
  • MERGEABLE / CLEAN, no ❌ on the thread

On the change itself: one derivation (register_for_reading in emrg/config.py) is now what every in-package register reader reads through, instead of each of them asking agents_file() and getting an empty answer whenever the host's register is still at its pre-rename name. It is deliberately not folded into agents_file(): migrate_legacy_registry uses that function's return value as the move target, so a fallback inside it would turn the migration into a permanent no-op. The mutation arms showed that — reverting the fallback alone reddens five tests, the migration's own among them.

The consequence the fix reaches is the one that was silent: submit_rant's typo warning is if registered and agent not in registered, so an empty list accepted a misspelled agent name without a word. With the register read correctly, the list holds 34 names again and the warning fires.

@argszero
argszero merged commit 2847006 into master Oct 23, 2026
2 checks passed
argszero added a commit that referenced this pull request Oct 23, 2026
…belongs at (#2550)

* emrg: the daemon's register is the file that exists, not the name it belongs at (#2549)

`EmrgServer.__init__` built its registry over `agents_file(runtime_dir)` — the name the
register belongs at. `AgentRegistry._read` answers a *missing* file with `[]`, and
`for_dir` writes back what it read, so on a host whose move to that name could not happen
(a read-only home, a permission boundary, a caller that got there first — all states
`migrate_legacy_registry` documents and leaves the legacy file for) the first session
opened wrote a second register holding only its own row, and readers prefer the new name
once it exists, so every entry left at the old one vanished from view silently.

The handle is now `register_for_reading(runtime_dir)`, the derivation #2546 introduced:
read and write address one file, and it is the file that is there. A host already at the
new name is unchanged, and so is one with no register at all.

Same shape #2548 removed from the scheduler's self-heal, at the call site that runs on
every session open. The test suite's register redirection moves onto the new seam, and
the new test is red without the change.

* emrg: the red direction of the register test is pinned mechanically (#2549)

The canonical-name monkeypatch in the new test exists for the mutant, not for the fixed
code: a regression that asks `agents_file` again resolves through that module global, so
the arm fails on the assertion that names the defect instead of writing a host's register.
Deleting the pin would have made the test pass for the wrong reason, so an assertion now
holds it in place — and removing `raising=False` reports the missing attribute by name.

* emrg: the fresh-install half of the register claim is pinned (#2549)

The PR body says a host already at the new name is unchanged "and so is one with no
register at all" — and nothing tested the second half. `_register_agent` had exactly one
caller in the suite, the disagreeing-name test, so a fix that redirected a new register to
the legacy name, or stopped creating one, would have gone unnoticed.

Both derivations are pinned to one temporary path so the test is hermetic under a mutant;
the disagreeing-name test stays the one that discriminates, and says so in the new test's
docstring rather than leaving a reader to infer it.

---------

Co-authored-by: EMRG Evolution <emrg@argszero.dev>
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: a register still at its pre-rename name reads as an empty register to every in-package reader

1 participant