diff --git a/emrg/server/scheduler.py b/emrg/server/scheduler.py index 574e11b5..d565cc47 100644 --- a/emrg/server/scheduler.py +++ b/emrg/server/scheduler.py @@ -3713,7 +3713,29 @@ def _ensure_emrg_agent_entry(self) -> None: # The write goes through `AgentRegistry`, so the register keeps one writer # and one temp prefix; only the row's *meaning* (the evolution workspace) is # the scheduler's to decide. - reg = AgentRegistry(agents_file(config_dir())) + # + # Read and write must address the *same* file, and it must be the one the + # register is actually at. `agents_file` names where the register belongs, + # and a register still at its pre-rename name is what `register_for_reading` + # answers — the derivation every reader in this package now uses (#2546). + # Asking `agents_file` here instead put the two halves at two different + # files: `AgentRegistry._read` maps a missing file to `[]`, so this saw no + # `emrg` row, upserted one, and created a *second* register holding only + # that row — which, from the next call on, is the file `register_for_reading` + # prefers, so every other entry vanished from every reader. A write that + # cannot address the register it just read is worse than no write, so when + # the two names disagree this declines and leaves the move to + # `migrate_legacy_registry`, which the daemon runs at startup, before this + # function exists. + register = register_for_reading(config_dir()) + if register != agents_file(config_dir()): + logger.info( + "TaskScheduler: agents.yml is still at %s — leaving the move to the " + "startup migration rather than writing a second register", + register.name, + ) + return + reg = AgentRegistry(register) target = str(Path.home() / ".emrg" / "evolution" / "emrg") try: row = next((e for e in reg.entries() if e.get("name") == "emrg"), None) diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 06bcdb70..a1ffe703 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -1892,6 +1892,55 @@ def test_ensure_emrg_agent_entry_other_entries_preserved(tmp_path): assert "emrg" in names assert len(names) == 2 + +def test_ensure_emrg_agent_entry_declines_when_the_register_is_at_the_legacy_name(tmp_path): + """The read half and the write half must address the same file. + + A register still at the pre-rename name is a state the move can be stuck in + (a read-only home, a permission boundary, a concurrent caller) and the state + `register_for_reading` exists for. Asking `agents_file` alone here did not + read that register — `AgentRegistry._read` maps a missing file to `[]` — so + the emrg row looked absent, and the upsert created a *second* register under + the new name holding only that row. Worse than the write itself: the new file + is the one `register_for_reading` prefers from then on, so the entries left + at the old name disappeared from every reader at once. + + The emrg row here is deliberately a dead path, which is the shape that gets + past the "an existing entry with a live directory is left alone" early return + and really reaches the write. + """ + from emrg.config import register_for_reading + from emrg.server import scheduler as mod + from emrg.server.scheduler import TaskScheduler + + legacy = tmp_path / "projects.yml" + before = yaml.safe_dump([ + {"name": "emrg", "path": str(tmp_path / "gone" / "emrg")}, # dead on purpose + {"name": "other", "path": str(tmp_path / "other")}, + ]) + legacy.write_text(before, encoding="utf-8") + + sched = TaskScheduler(InstanceIdentity()) + + orig_config = mod.config_dir + try: + mod.config_dir = lambda: tmp_path + sched._ensure_emrg_agent_entry() + finally: + mod.config_dir = orig_config + + assert not (tmp_path / "agents.yml").exists(), ( + "a second register was created: the write half did not address the file " + "the read half had just read" + ) + assert legacy.read_text(encoding="utf-8") == before, "the legacy register is untouched" + register = register_for_reading(tmp_path) + assert register == legacy + assert [e["name"] for e in yaml.safe_load(register.read_text(encoding="utf-8"))] == [ + "emrg", "other", + ], "every entry the register held is still there" + + # ── TaskHandler cycle truncation detection ────────────────── # mem-repo lesson (tool-call truncation must be flagged, not silently # treated as a successful/empty cycle — #523 applied it to the chat UI;