From 45533882362a47c47df4138c0f5868163e8a99db Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Fri, 23 Oct 2026 14:38:16 +0800 Subject: [PATCH 1/2] emrg: the register is written at session creation, and a directory is registered as itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent register's write moves from "an inbound message carried a cwd" to "a session was opened in this directory", and the three normalisations that stood between a directory and its row are gone: realpath, the merge into a longest known parent, and the basename-only name. Registration therefore covers every session a client opens and none of the sessions the server creates for itself. The sub-session slot and the boss session are declined by path — they live under another Agent's .emrg/ — and the upgrade session, which runs in the installation's own work directory, by a keyword at its one call site. Names are unique: a basename already taken becomes name-2, name-3. A task resolves its directory by name, so two rows sharing one silently misroutes it. The logic moves out of daemon.py into emrg/server/agents.py::AgentRegistry — pure, no daemon import, no server state — and the scheduler's self-heal writes through it too, so the file keeps one writer. An unreadable register is now left unread by both writers rather than rebuilt from nothing: a read-then-rewrite path answering an unparseable file with an empty one deletes every row the host had. --- emrg/server/agents.py | 274 ++++++++++++++ emrg/server/daemon.py | 370 ++++++------------- emrg/server/scheduler.py | 70 ++-- tests/conftest.py | 41 +- tests/test_agent_md_prompt_cap.py | 2 +- tests/test_agent_registry.py | 195 ++++++++++ tests/test_boss_tool.py | 2 +- tests/test_daemon.py | 57 +-- tests/test_hermeticity_guard.py | 13 +- tests/test_local_exclude.py | 20 +- tests/test_memory_block_leaves_the_prefix.py | 2 +- tests/test_memory_row_bound_reach.py | 2 +- tests/test_message_time_is_the_daemons.py | 2 +- tests/test_project_context_paths.py | 2 +- tests/test_prompt_templates.py | 2 +- tests/test_pwsh_tool_v2.py | 2 +- tests/test_redaction.py | 2 +- tests/test_sandbox_escalation.py | 2 +- tests/test_shell_tool_mount.py | 2 +- tests/test_skills_read_live.py | 2 +- tests/test_subagent_tool.py | 2 +- tests/test_tool_argument_domains.py | 2 +- tests/test_tool_call_batch_label.py | 2 +- tests/test_tool_set_per_message.py | 2 +- tests/test_turn_context_is_persisted.py | 2 +- tests/test_turn_terminal_frame.py | 2 +- tests/test_upgrade.py | 6 +- 27 files changed, 719 insertions(+), 363 deletions(-) create mode 100644 emrg/server/agents.py create mode 100644 tests/test_agent_registry.py diff --git a/emrg/server/agents.py b/emrg/server/agents.py new file mode 100644 index 00000000..c0146cbe --- /dev/null +++ b/emrg/server/agents.py @@ -0,0 +1,274 @@ +"""The agent register: the directories this installation has opened sessions in. + +``agents.yml`` is a YAML list, one row per directory:: + + - path: /Users/argszero/work/emrg # verbatim str(session.cwd) + name: emrg # the alias a task points at; globally unique + last_active: 2026-10-23T14:20:00.123456 + +This module is the only code that **writes** that file, and it is pure logic — it +imports neither the daemon nor any server state — so its rules are testable on their +own. The shape mirrors ``emrg/server/rants.py``. Readers elsewhere (the scheduler's +name-to-path resolution, the sandbox's protected-files list) read the same shape and +are unaffected by the rules below, which apply where the file is written. + +**One rule: a directory is registered as itself.** ``path`` is the string the session +holds, stored and compared without ``realpath``, ``expanduser`` or trailing-slash +normalisation. A symlinked directory, a relative spelling and the canonical one are +three different rows, because they are three different strings: the register answers +"which directory did this session run in", and it is not the register's business to +guess which other spelling of it someone meant. What reads the column — the sessions +index, a task's resolved path — compares it against a value that came from a session, +so collapsing spellings here is what makes the two disagree. + +**Names are unique across the register.** A name starts as the directory's basename, +and a basename already taken gets ``-2``, then ``-3``. ``scheduler._resolve_agent_path`` +looks a directory up *by name*, so two rows sharing one would silently point a task at +whichever was written last. The suffix is allocated when the row is created, against +the names present at that moment; it is not part of the row's identity, and removing a +row frees its name. + +Two directories are not registered, and each for a stated reason rather than a guess: + +* **the home directory** — a session opened there would put its own session directory + under ``~/.emrg/sessions/``, beside the runtime configuration. That mixes "where this + installation keeps its files" into "a directory someone works in", and the register + is the second thing only; +* **any directory under an existing Agent's ``.emrg/``** — the sub-session slot, the + boss session, every directory the server derives. Those are that Agent's own + bookkeeping, reached through its path; they are not directories a host opened, and a + row for one would claim the bookkeeping is a workspace. The test is the path alone, + so it needs no name, no flag and no knowledge of who created the session. + +Kept from this module's predecessor, because neither is a normalisation: + +* ``ensure_local_exclude`` — registration is the moment a directory is taken on as an + Agent, and so the moment it learns to ignore the ``.emrg/`` EMRG will write into it. + The task-level dirty-tree guard reads ``git status``, and this keeps this + installation's own bookkeeping out of that verdict. It is called when a **row is + created**, not on every refresh: the ignore line is written once, and re-running a + git subprocess per message would be work with no effect. +* **the non-regular-file refusal** — ``agents.yml`` is opened directly, and an open on + a FIFO blocks until a writer appears while a socket waits for a connection. This runs + on the daemon's event loop, so the block would be the whole daemon. A file that + cannot be read therefore leaves the register **unread and unrewritten**, rather than + being answered with an empty one — which, on this method's read-then-rewrite path, + would delete every row the host had, to report a problem it never mentioned. +""" + +from __future__ import annotations + +import logging +import os +from datetime import datetime +from pathlib import Path + +import yaml + +from emrg.server.atomic import atomic_write_yaml +from emrg.server.git_utils import ensure_local_exclude +from emrg.tools.base import non_regular_kind + +logger = logging.getLogger(__name__) + +# Fields a row carries. Anything else that a hand-edited or older file holds is +# dropped on write: `auto_evolve` and `interval` described a registry that also +# scheduled, which the task scheduler took over (`tasks.yml` owns scheduling now). +_ROW_FIELDS = ("path", "name", "last_active") + + +def _unique_name(base_name: str, taken: set[str]) -> str: + """``base_name``, or the first free ``base_name-N`` — the register's naming rule.""" + if base_name not in taken: + return base_name + n = 2 + while f"{base_name}-{n}" in taken: + n += 1 + return f"{base_name}-{n}" + + +def _kind_refusal(path: Path, kind: str) -> str: + """The one sentence both of this module's readers say about a subject they refuse.""" + return ( + f"{path} is {kind}, not a regular file. The register is opened directly, and " + f"opening this one blocks until a peer appears — a FIFO waits for a writer, a " + f"socket for a connection, a device never ends — so the call would never return, " + f"and neither would the daemon, which reads it on its event loop." + ) + + +class AgentRegistry: + """The register as a file: read it, register a directory in it, drop a name from it. + + Holds the path and nothing else. Every method reads the file, so two instances + pointed at one path cannot disagree about what it says — which is what lets the + daemon hand out a fresh one wherever it needs it. + """ + + def __init__(self, path: Path | str) -> None: + self.path = Path(path) + + # ── reading ────────────────────────────────────────────────────────── + + def _read(self) -> list[dict] | None: + """Every row in file order, or ``None`` when the file could not be read. + + ``None`` and ``[]`` are different answers and the difference is the point: no + file yet is a register with no rows, while a file that *is* there and cannot be + read — a FIFO, a directory, a document this module cannot parse — is one whose + contents are unknown. + + Both writers answer ``None`` by doing nothing, and that is the whole reason it + exists. The predecessor rebuilt the register from an unparseable file, which on + a read-then-rewrite path means **deleting every row the host had** in order to + report a problem it never mentioned; and the removal path answered the same + file with "there is no such entry", which is a claim about rows this call never + saw (#2062). + """ + try: + kind = non_regular_kind(self.path) + except FileNotFoundError: + return [] + if kind is not None: + logger.error("%s — leaving the agent register unread", _kind_refusal(self.path, kind)) + return None + try: + data = yaml.safe_load(self.path.read_text(encoding="utf-8")) + except (yaml.YAMLError, OSError, UnicodeDecodeError): + logger.error( + "cannot read %s — leaving the agent register unread; repair or remove " + "the file and the next registration rebuilds it", self.path, + exc_info=True, + ) + return None + if not isinstance(data, list): + # An empty file parses as `None`, and so does a file holding a mapping: both + # mean "no rows" to every reader here, and neither is a reason to refuse. + return [] + rows: list[dict] = [] + for entry in data: + if not isinstance(entry, dict) or not entry.get("path"): + continue + row = {k: entry.get(k) for k in _ROW_FIELDS} + row["path"] = str(row["path"]) + row["name"] = str(row["name"]) if row["name"] else os.path.basename( + row["path"].rstrip(os.sep) + ) or row["path"] + rows.append(row) + return rows + + def entries(self) -> list[dict]: + """Every row, for a caller that only reads — path-sorted, as the file is written. + + A register that could not be read answers no rows here: the caller is asking + what to *display*, and there is nothing to display. The refusal is logged, and + the writer paths (`for_dir`, `remove`) still tell it from an empty file. + """ + rows = self._read() + return [] if rows is None else rows + + def paths(self) -> list[str]: + """Just the directories, for a caller that indexes them.""" + return [row["path"] for row in self.entries() if row.get("path")] + + # ── writing ────────────────────────────────────────────────────────── + + def _write(self, rows: list[dict]) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + atomic_write_yaml( + sorted(rows, key=lambda row: row.get("path", "")), + self.path, + prefix=".agents_", + ) + + def for_dir(self, cwd: str | os.PathLike[str]) -> dict: + """The row for a directory, created in place when the register lacks one. + + Returns the row — ``{"path", "name", "last_active"}`` — or ``{}`` when the + directory is one of the two this module declines to register (the module + docstring says which, and why). + """ + base = str(cwd) + if not base: + return {} + rows = self._read() + if rows is None: + return {} + + # The home directory is the one *path* exception kept. + if base == os.path.expanduser("~"): + return {} + + now = datetime.now().isoformat() + + # An existing row is refreshed in place, before any other test: a directory + # already in the register has been taken on, whatever it looks like now. + for row in rows: + if row.get("path") == base: + row["last_active"] = now + self._write(rows) + return row + + # A directory under an existing Agent's `.emrg/` is that Agent's bookkeeping + # (the sub-session slot, the boss session), not a directory a host opened. + for row in rows: + inner = os.path.join(row.get("path") or "", ".emrg") + if base == inner or base.startswith(inner + os.sep): + return {} + + status = ensure_local_exclude(base) + if status == "added": + logger.info( + "register: %s now ignores .emrg/ locally (its own .git/info/exclude)", + base, + ) + + name = _unique_name( + os.path.basename(base.rstrip(os.sep)) or base, + {row.get("name") for row in rows if row.get("name")}, + ) + row = {"path": base, "name": name, "last_active": now} + rows.append(row) + self._write(rows) + logger.info("agent registered: %s as %s", base, name) + return row + + def upsert(self, name: str, path: str) -> dict: + """Create the row named ``name``, or move the row already carrying that name. + + For a caller that owns a row by name rather than by directory — the scheduler's + self-heal, which keeps an ``emrg`` row pointing at the evolution workspace. It + writes through this module so the file keeps one writer and one temp prefix. + """ + rows = self._read() + if rows is None: + return {} + now = datetime.now().isoformat() + for row in rows: + if row.get("name") == name: + row["path"] = str(path) + row["last_active"] = now + self._write(rows) + return row + row = {"path": str(path), "name": name, "last_active": now} + rows.append(row) + self._write(rows) + return row + + def remove(self, name: str) -> bool | None: + """Drop the row named ``name``: ``True`` removed, ``False`` no such row. + + ``None`` is the third answer, and not a synonym for "no such row": the register + could not be read, so nothing was decided and nothing was written. A caller that + reports ``False`` for it tells the host "there is no such entry" about a file it + never read. + """ + rows = self._read() + if rows is None: + return None + remaining = [row for row in rows if row.get("name") != name] + if len(remaining) == len(rows): + return False + self._write(remaining) + logger.info("remove: dropped %s from %s", name, self.path) + return True diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index dc45b2e1..84c1ae44 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -49,7 +49,8 @@ ) from emrg.connect import EMRGD_PORT, cleanup_server, is_server_running_sync from emrg.server.abort_runs import AbortRuns -from emrg.server.atomic import atomic_write_bytes, atomic_write_yaml +from emrg.server.agents import AgentRegistry +from emrg.server.atomic import atomic_write_bytes from emrg.server.config_reload import ( POLL_INTERVAL_SECONDS, ConfigReloader, @@ -69,7 +70,6 @@ ) from emrg.server.git_utils import ( _detect_git_remote, - ensure_local_exclude, no_prompt_env, parse_gh_auth_user, resolve_git_gh, @@ -869,7 +869,7 @@ def __init__(self, llm_config: LlmConfig) -> None: # (session_id → (text, injected_ms)) so unchanged context is not # re-sent (context_refresh_interval_ms gating). self._context_snapshots: dict[str, tuple[str, float]] = {} - self._projects_log = agents_file(runtime_dir) + self._agents_log = agents_file(runtime_dir) self._rants_log = runtime_dir / "rants.jsonl" # ── Phase 2 broadcast model (protocol-contract §2.6) ── @@ -1256,30 +1256,7 @@ def _rebuild_sessions_index(self) -> None: from emrg.sessions_index import rebuild_sessions_index try: - project_paths: list[str] = [] - kind = _non_regular_kind(self._projects_log) - if kind is not None: - # Not opened, and **said so** rather than caught and dropped: the reader - # below answers a registry it cannot read with no paths, and the - # difference between "the file is not there" and "the file cannot be - # read to an end" is the whole point of asking the kind (`#2062`). A FIFO - # here would hold this startup task — and the event loop with it — for - # ever, before any client can connect. - logger.debug( - "sessions index backfill: %s is %s, not a regular file; not opened", - self._projects_log, kind, - ) - elif self._projects_log.exists(): - try: - data = yaml.safe_load(self._projects_log.read_text(encoding="utf-8")) - if isinstance(data, list): - project_paths = [ - e.get("path", "") - for e in data - if isinstance(e, dict) and e.get("path") - ] - except (yaml.YAMLError, OSError): - pass + project_paths: list[str] = self._agents.paths() count = rebuild_sessions_index(config_dir(), project_paths) logger.info("sessions index rebuilt: %d sessions indexed", count) except Exception: @@ -1460,7 +1437,10 @@ async def _run_upgrade_session(self, session_id: str, cwd: str, prompt: str) -> # re-triggered by the next tick (version.txt unchanged). self._session_pending.setdefault(session_id, []).append((req, True)) return - session = self._get_or_create_session(session_id, Path(cwd)) + # `register=False`: this session's directory is the installation's own upgrade + # work area (`UPGRADE_WORK_DIR`), not a directory a host opened, and a register + # row for it would claim the installation's bookkeeping is a workspace. + session = self._get_or_create_session(session_id, Path(cwd), register=False) self._session_busy[session_id] = True cancel_event = asyncio.Event() # Publish the turn's cancel **event** under its session, exactly as the read @@ -1657,8 +1637,10 @@ async def _handle_client(self, ws) -> None: # task's cwd, and that is the sender's own. self._session_subscribers.setdefault(new_sid, {})[ws] = data["cwd"] if data.get("cwd"): + # Kept for the subscriber record below; registration no longer + # happens here — a message that names a directory is not a session + # opened in it (`_get_or_create_session` registers). last_cwd = data["cwd"] - self._touch_project(last_cwd) # ── Cancel: interrupt running tool loop ────────── if data.get("type") == "approval_response": @@ -2101,129 +2083,33 @@ async def _broadcast_all(self, data: dict, exclude=None) -> None: except Exception: pass # individual connection failure is non-fatal - # ── Project tracking ───────────────────────────────────── - - def _touch_project(self, cwd: str) -> None: - """Record a project as active in ~/.emrg/agents.yml. - - Used by the evolution cycle to discover which projects have - recent user activity and analyze their sessions for improvement ideas. - - Normalizes the path via realpath() so symlinked directories don't - cause duplicate entries. - owner/repo is detected at runtime from git remote, not stored. + # ── Agent register ─────────────────────────────────────── + # + # The register itself is `emrg/server/agents.py`: pure logic, no daemon import and + # no server state. What stays here is the one instance and the places that call it. + + @property + def _agents_log(self) -> Path: + """Where the agent register (``agents.yml``) lives.""" + return self._agents.path + + @_agents_log.setter + def _agents_log(self, path: Path | str) -> None: + # A setter rather than a plain attribute, so that a reassignment cannot leave + # the daemon holding a registry pointed at the file it used to name. The + # registry keeps no state of its own — every call reads the file — so replacing + # the instance costs nothing. + self._agents = AgentRegistry(Path(path)) + + def _register_agent(self, cwd: Path) -> dict: + """Take the directory a session runs in into the register, and answer its row. + + Called where a session is opened, which is when a directory becomes an Agent. + `AgentRegistry.for_dir` owns every rule about *whether* — the home directory and + the directories under an Agent's own `.emrg/` are declined there — so this is + the daemon's one line of policy: a client's session registers. """ - cwd = os.path.realpath(cwd) - # Don't track the home directory as a project - home = os.path.expanduser("~") - if cwd == home: - return - # Registration is where this instance takes a directory on as a - # project, so it is where the directory learns to ignore the `.emrg/` - # runtime data EMRG will write into it (rant 2026-09-21T10:12:01). - # Idempotent: an explicit call, not a guess — `git status` is what the - # task-level dirty-tree guard reads, and this keeps this instance's own - # bookkeeping out of that verdict. - # - # Unconditional, because the callee answers the question this site used - # to guess at (issue #1527): testing `/.git` reads "is this the root - # of a checkout?", while the question is "is this directory in a tree?" - # — and the two differ for every directory that is not a root (a package - # in a monorepo, a `work/` tree, a clone nested in a larger repository), - # which is exactly where EMRG's runtime data stayed unignored. The entry - # is scoped by the enclosing repository's own answer, so a nested - # directory gets `//.emrg/` and the root's `/.emrg/` is not a - # substitute for it. `ensure_local_exclude` refuses by itself where there - # is nothing to write — a directory in no repository or one that does not - # exist answers `"not-a-repo"`, writing nothing and logging nothing — so - # the marker test added suppression and nothing else. - exclude_status = ensure_local_exclude(cwd) - if exclude_status == "added": - logger.info( - "_touch_project: %s now ignores .emrg/ locally " - "(its own .git/info/exclude)", cwd, - ) - self._projects_log.parent.mkdir(parents=True, exist_ok=True) - now = datetime.now().isoformat() - - # Read existing entries (normalize by realpath to avoid duplicates) - projects: dict[str, dict] = {} - kind = _non_regular_kind(self._projects_log) - if kind is not None: - # Skipped, and skipped **before** the read rather than inside the `except` - # below: this method reads the registry and then rewrites it, so falling - # through to the "rebuilding" branch would answer a host file it could not - # read with an **empty** registry — deleting every project the host - # registered, in one write, to report a problem it never mentioned. - # Registration is bookkeeping, so the honest reading is "not this time": - # the file keeps what it had and the warning names the kind. - logger.warning( - "_touch_project: %s is %s, not a regular file — leaving the project " - "registry unread and unrewritten", self._projects_log, kind, - ) - return - if self._projects_log.exists(): - try: - data = yaml.safe_load(self._projects_log.read_text(encoding="utf-8")) - if isinstance(data, list): - for entry in data: - if isinstance(entry, dict) and entry.get("path"): - key = os.path.realpath(entry["path"]) - # Strip dead fields from pre-task-scheduler era - entry.pop("auto_evolve", None) - entry.pop("interval", None) - projects[key] = entry - except (yaml.YAMLError, TypeError, OSError): - logger.warning( - "_touch_project: failed to parse %s, rebuilding", - self._projects_log, - exc_info=True, - ) - - # Update or add entry - if cwd in projects: - projects[cwd]["last_active"] = now - else: - # Check if cwd is a subdirectory of an existing project. - # Prefer the longest matching parent path (most specific). - parent = "" - for known_path in projects: - if cwd.startswith(known_path + os.sep): - if len(known_path) > len(parent): - parent = known_path - # Determine whether to merge into parent or create a new entry. - # If cwd is itself a git repo root, or if the parent is the home - # directory (which is not a real project), create a new entry. - is_git_root = os.path.isdir(os.path.join(cwd, ".git")) - if parent and not is_git_root and parent != home: - # Merge into parent: child directory with no git repo of its own - projects[parent]["last_active"] = now - elif parent == home or (parent and is_git_root): - # Parent is home dir or cwd is a git repo → create new entry - name = os.path.basename(cwd.rstrip("/")) - projects[cwd] = { - "name": name, - "path": cwd, - "last_active": now, - } - logger.info("new project tracked (git root under parent): %s", name) - # Clean up stale home-dir entry if it exists - if home in projects: - del projects[home] - logger.info("removed stale home-dir project entry") - else: - name = os.path.basename(cwd.rstrip("/")) - projects[cwd] = { - "name": name, - "path": cwd, - "last_active": now, - } - logger.info("new project tracked: %s", name) - - # Build sorted YAML list - entries = sorted(projects.values(), key=lambda e: e.get("path", "")) - - atomic_write_yaml(entries, self._projects_log, prefix=".projects_") + return self._agents.for_dir(str(cwd)) async def _check_github_auth(self) -> dict: """Detect whether GitHub auth is configured (rant 2026-08-07T10:17:27). @@ -3037,12 +2923,17 @@ async def _process_message( elif msg_type == "init_auto_evolve": cwd = msg.get("cwd", "") if cwd: - self._touch_project(cwd) + # Registering here is deliberate and is not the message hook returning + # by another door: auto-evolve is a host asking for a directory to be + # worked in, and the task it creates must name the row's own name — + # `src` and `src-2` are two directories, and the basename is only the + # first one's name. + _row = self._register_agent(Path(cwd)) # Create a task entry in tasks.yml — through task_create, because the # legacy `create_task` was a second, unvalidated path to the same file # (rant 2026-09-28T09:54:10, D8). Enabling auto-evolve twice is a # no-op rather than an error: the intent is already satisfied. - name = os.path.basename(cwd.rstrip("/")) + name = _row.get("name") or os.path.basename(cwd.rstrip("/")) if self._scheduler: ok, res = self._scheduler.task_create( name=name, task_type="evolution", @@ -3936,8 +3827,24 @@ async def _process_message( "received": msg_type, }) - def _get_or_create_session(self, session_id: str, cwd: Path) -> Session: - """Load an existing session or create a new one.""" + def _get_or_create_session( + self, session_id: str, cwd: Path, *, register: bool = True + ) -> Session: + """Load an existing session or create a new one. + + Registering here — rather than on an inbound message carrying a ``cwd``, which + is where it used to happen — is what makes "a directory becomes an Agent" true + of the moment a session is opened in it. The old hook only ever saw a client's + message, so every session the server creates itself went unregistered, and a + client that merely mentioned a directory registered one nothing ran in. + + ``register=False`` is for the one session that has no directory a host opened: + the upgrade session runs in the installation's own work directory. Every other + server-created directory — the sub-session slot, the boss session — is declined + by path instead, in `AgentRegistry.for_dir`. + """ + if register: + self._register_agent(cwd) session_path = session_dir(cwd, session_id) if session_path.exists() and (session_path / "meta.json").exists(): return Session.load(session_id, cwd) @@ -6685,132 +6592,89 @@ async def _handle_list_sessions( async def _handle_list_projects( self, ws ) -> None: - """Read agents.yml and return all project entries. + """Return every registered Agent, ordered by its latest session activity. - Ordered by each project's latest session activity (created_at desc, - parallel scan) — P6 of the GUI multi-session rant (2026-08-10T15:07:19) - so the open/new-session dialogs show the most recently active projects - first. + The rows come from `AgentRegistry`. What stays here is the part that needs the + daemon: each directory's latest session ``created_at`` (P6 of the GUI + multi-session rant, 2026-08-10T15:07:19, so the open/new-session dialogs show + the most recently active first) and its git remote. - No evolution-workspace filter (rant 2026-08-07T10:48:00): agents.yml - only contains explicitly registered entries, and on packaged installs - the emrg project's only path IS ~/.emrg/evolution/emrg — filtering it - hid emrg from /rant entirely. + No evolution-workspace filter (rant 2026-08-07T10:48:00): the register holds the + directories this installation has opened sessions in, and on packaged installs + the emrg project's only path IS ~/.emrg/evolution/emrg — filtering it hid emrg + from /rant entirely. """ - projects: list[dict] = [] - try: - if self._projects_log.exists(): - # Every client asks this on connect, so an unguarded open here is the - # daemon's event loop stopping on the first frame a GUI or TUI sends — - # the refusal lands in the `except` below, which is already this - # method's answer for a registry it cannot read (`#2062`). - _refuse_non_regular_file(self._projects_log) - data = yaml.safe_load(self._projects_log.read_text(encoding="utf-8")) - if isinstance(data, list): - entries = [p for p in data if isinstance(p, dict)] - # P6 验收(rant 2026-08-10T15:07:19):项目按"该项目最新会话活跃"倒序 - # ——并行扫描各项目 sessions 目录取最大 created_at(GUI 单连接无法并发 - # list_sessions:_pending 按 respType 键控会互相覆盖,故 daemon 侧聚合)。 - async def _latest_session_at(entry: dict) -> str: - p = entry.get("path", "") - if not p: - return "" - try: - sessions = Session.list_sessions(Path(p)) - return sessions[0].get("created_at", "") if sessions else "" - except (OSError, ValueError, json.JSONDecodeError): - return "" - - ats = await asyncio.gather(*(_latest_session_at(e) for e in entries)) - ordered = sorted(zip(entries, ats), key=lambda t: t[1], reverse=True) - # rant 2026-08-19T01:05:47 — _detect_git_remote runs a sync - # git subprocess per project; offload to worker threads so a - # slow git probe never freezes the event loop (websocket - # clients would otherwise time out during list_projects). - repos = await asyncio.gather(*( - asyncio.to_thread(_detect_git_remote, p.get("path", "")) - for p, _ in ordered - )) - projects = [ - {"name": p.get("name", ""), - "repo": repo, - "path": p.get("path", ""), - "latest_session_at": at} - for (p, at), repo in zip(ordered, repos) - ] - except (yaml.YAMLError, OSError): - logger.exception("Failed to read agents.yml") + entries = self._agents.entries() + + # P6 验收(rant 2026-08-10T15:07:19):项目按"该项目最新会话活跃"倒序 + # ——并行扫描各项目 sessions 目录取最大 created_at(GUI 单连接无法并发 + # list_sessions:_pending 按 respType 键控会互相覆盖,故 daemon 侧聚合)。 + async def _latest_session_at(entry: dict) -> str: + p = entry.get("path", "") + if not p: + return "" + try: + sessions = Session.list_sessions(Path(p)) + return sessions[0].get("created_at", "") if sessions else "" + except (OSError, ValueError, json.JSONDecodeError): + return "" + + ats = await asyncio.gather(*(_latest_session_at(e) for e in entries)) + ordered = sorted(zip(entries, ats), key=lambda t: t[1], reverse=True) + # rant 2026-08-19T01:05:47 — _detect_git_remote runs a sync git subprocess per + # project; offload to worker threads so a slow git probe never freezes the + # event loop (websocket clients would otherwise time out during list_projects). + repos = await asyncio.gather(*( + asyncio.to_thread(_detect_git_remote, p.get("path", "")) + for p, _ in ordered + )) + projects: list[dict] = [ + {"name": p.get("name", ""), + "repo": repo, + "path": p.get("path", ""), + "latest_session_at": at} + for (p, at), repo in zip(ordered, repos) + ] await self._send(ws, { "type": "projects_list", "projects": projects, }) async def _handle_remove_project(self, name: str, ws) -> None: - """Remove a project entry from agents.yml by name. + """Drop one row from the register by name; the directory's data stays. - P1 of the GUI multi-session feature (rant 2026-08-10T15:07:19): - deletes only the agents.yml entry — on-disk session data under - /.emrg/sessions/ is preserved (a later _touch_project - re-registers the project). Mirrors _touch_project's read path and - atomic_write_yaml. Responses: + Deletes only the ``agents.yml`` row — what is on disk under + ``/.emrg/sessions/`` is preserved, and opening a session there again + registers the directory anew (P1 of the GUI multi-session feature, + rant 2026-08-10T15:07:19). Responses: {"type": "project_removed", "removed": true, "name": name} {"type": "project_removed", "removed": false, "name": name, ...} """ - if not self._projects_log.exists(): - await self._send(ws, { - "type": "project_removed", - "removed": False, - "name": name, - }) - return try: - # The same refusal as `_handle_list_projects`, and this caller already has - # the branch for it: an unreadable registry is answered with `removed: - # false` and the reason, so a FIFO cannot hold the event loop (`#2062`). - _refuse_non_regular_file(self._projects_log) - data = yaml.safe_load(self._projects_log.read_text(encoding="utf-8")) - except (yaml.YAMLError, OSError): - logger.exception("remove_project: failed to read %s", self._projects_log) - await self._send(ws, { - "type": "project_removed", - "removed": False, - "name": name, - "error": "failed to read agents.yml", - }) - return - if not isinstance(data, list): - await self._send(ws, { - "type": "project_removed", - "removed": False, - "name": name, - }) - return - remaining = [ - e for e in data - if not (isinstance(e, dict) and e.get("name") == name) - ] - if len(remaining) == len(data): + removed = self._agents.remove(name) + except OSError: + logger.exception("remove: failed to write %s", self._agents_log) await self._send(ws, { "type": "project_removed", "removed": False, "name": name, + "error": "failed to write agents.yml", }) return - try: - atomic_write_yaml(remaining, self._projects_log, prefix=".projects_") - except OSError: - logger.exception("remove_project: failed to write %s", self._projects_log) + if removed is None: + # Not "no such entry": the register could not be opened, so nothing was + # decided. Answering `removed: false` here would tell the host there is no + # such row about a file this method never read (`#2062`). await self._send(ws, { "type": "project_removed", "removed": False, "name": name, - "error": "failed to write agents.yml", + "error": "failed to read agents.yml", }) return - logger.info("remove_project: removed %s from agents.yml", name) await self._send(ws, { "type": "project_removed", - "removed": True, + "removed": bool(removed), "name": name, }) diff --git a/emrg/server/scheduler.py b/emrg/server/scheduler.py index 21884acd..04590247 100644 --- a/emrg/server/scheduler.py +++ b/emrg/server/scheduler.py @@ -6,7 +6,7 @@ that has none and can do nothing else, so a write cannot disturb a running cycle (rant 2026-09-28T09:54:10). -agents.yml remains for project tracking (_touch_project only). +agents.yml remains for agent registration (emrg/server/agents.py only). Task config schema: name, type, enabled, interval — common base fields. @@ -47,6 +47,7 @@ from emrg.sandbox.policy import SANDBOX_MODES from websockets.exceptions import ConnectionClosed from emrg.protocol import EvolutionLog, InstanceIdentity +from emrg.server.agents import AgentRegistry from emrg.server.atomic import atomic_write_yaml # The credential rule, imported rather than restated: this module logs text it did not # author — an error string off the daemon's wire, and an abort reason built from an @@ -3645,50 +3646,33 @@ def _ensure_emrg_project_entry(self) -> None: path is fixed to ~/.emrg/evolution/emrg; an existing entry is preserved as-is (dev machines may configure a custom path). """ - # agents.yml — add name=emrg entry if missing (preserve existing). - projects_file = agents_file(config_dir()) + # 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())) + target = str(Path.home() / ".emrg" / "evolution" / "emrg") try: - entries: list[dict] = [] - if projects_file.exists(): - data = yaml.safe_load(_read_text(projects_file)) - if isinstance(data, list): - entries = [e for e in data if isinstance(e, dict)] - if not any(e.get("name") == "emrg" for e in entries): - entries.append({ - "name": "emrg", - "path": str(Path.home() / ".emrg" / "evolution" / "emrg"), - "last_active": datetime.now().isoformat(), - }) - atomic_write_yaml(entries, projects_file, prefix=".projects_") - logger.info( - "TaskScheduler: self-heal — added emrg entry to agents.yml" - ) - else: - # Repair a stale emrg entry whose path no longer exists - # (2026-08-12 incident: a pytest temp dir leaked into - # agents.yml by a test run; the dir is deleted after the - # suite, leaving the entry pointing at a dead path forever — - # list_projects/GUI pickers show a wrong path and the handler - # re-resolves a dangling dir every cycle). Dev machines with a - # real custom checkout keep their path (is_dir() True). - for entry in entries: - if entry.get("name") != "emrg": - continue - existing = entry.get("path") - if existing and Path(existing).is_dir(): - break # real checkout — preserved as-is - entry["path"] = str(Path.home() / ".emrg" / "evolution" / "emrg") - entry["last_active"] = datetime.now().isoformat() - atomic_write_yaml(entries, projects_file, prefix=".projects_") - logger.info( - "TaskScheduler: self-heal — repaired stale emrg entry " - "%r -> %s", existing, entry["path"], - ) - break - except (yaml.YAMLError, OSError) as e: - logger.warning( - "TaskScheduler: agents.yml self-heal failed: %s", e + row = next((e for e in reg.entries() if e.get("name") == "emrg"), None) + if row is None: + reg.upsert("emrg", target) + logger.info("TaskScheduler: self-heal — added emrg entry to agents.yml") + return + # Repair a stale emrg entry whose path no longer exists + # (2026-08-12 incident: a pytest temp dir leaked into agents.yml by a test + # run; the dir is deleted after the suite, leaving the entry pointing at a + # dead path forever — list_projects/GUI pickers show a wrong path and the + # handler re-resolves a dangling dir every cycle). Dev machines with a real + # custom checkout keep their path (is_dir() True). + existing = row.get("path") + if existing and Path(existing).is_dir(): + return + reg.upsert("emrg", target) + logger.info( + "TaskScheduler: self-heal — repaired stale emrg entry %r -> %s", + existing, target, ) + except OSError as e: + logger.warning("TaskScheduler: agents.yml self-heal failed: %s", e) # ── Task CRUD + hot reload (rant 2026-08-12T18:23:15 P2) ──────── diff --git a/tests/conftest.py b/tests/conftest.py index c622caa2..84d91e72 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -92,11 +92,18 @@ def _guard_real_config_files(monkeypatch): import builtins import io + import emrg.server.agents as agents_mod import emrg.server.daemon as daemon_mod import emrg.server.scheduler as sched_mod + # Every module that *writes* a config file imports the same function; a module + # that stops importing it stops being covered here, which is why each one is + # named rather than discovered. `agents.py` is the register's only writer since + # #2527, so the daemon no longer imports this at all. orig = sched_mod.atomic_write_yaml - assert orig is daemon_mod.atomic_write_yaml, "both modules must share atomic_write_yaml" + assert orig is agents_mod.atomic_write_yaml, ( + "the register's writer and the task table's must share atomic_write_yaml" + ) def guarded(data, path, **kwargs): if _is_real_config_target(path): @@ -104,7 +111,7 @@ def guarded(data, path, **kwargs): return orig(data, path, **kwargs) monkeypatch.setattr(sched_mod, "atomic_write_yaml", guarded) - monkeypatch.setattr(daemon_mod, "atomic_write_yaml", guarded) + monkeypatch.setattr(agents_mod, "atomic_write_yaml", guarded) orig_open = builtins.open orig_io_open = io.open @@ -136,6 +143,29 @@ def guarded_io_open(file, mode="r", *args, **kwargs): monkeypatch.setattr(io, "open", guarded_io_open) +@pytest.fixture(autouse=True) +def _redirect_the_agent_register(monkeypatch, tmp_path): + """Every test's agent register is its own file. + + Opening a session registers the directory it runs in (#2527), so the write now sits + on a path nearly every daemon test reaches: a test that builds an `EmrgServer` and + sends one message would otherwise append to the HOST's ``~/.emrg/agents.yml``. + Autouse for the same reason as the guard above — the test that would leak is the one + written three commits from now. + + The seam is ``agents_file``, which is how the daemon asks where the register lives. + A test that wants to inspect or steer the register sets ``server._agents_log`` + afterwards, and that assignment wins: the registry is rebuilt from it. + """ + import emrg.server.daemon as daemon_mod + + monkeypatch.setattr( + daemon_mod, + "agents_file", + lambda base=None: tmp_path / "agent-register" / "agents.yml", + ) + + @pytest.fixture(autouse=True) def _redirect_abort_run_state(monkeypatch, tmp_path): """Redirect the abort-run counter's state file to a per-test tmp path. @@ -1105,7 +1135,7 @@ def __init__(self, *args, **kwargs): # loud failure; tests that exercise the runner stub the factory after. _orig_get_or_create = daemon_mod.EmrgServer._get_or_create_session - def _guarded_get_or_create(self, session_id, cwd): + def _guarded_get_or_create(self, session_id, cwd, **kwargs): if session_id == up_mod.SESSION_ID: raise AssertionError( "test attempted to create the REAL emrg-upgrade session — ⛔ " @@ -1113,7 +1143,10 @@ def _guarded_get_or_create(self, session_id, cwd): "session factory (monkeypatch.setattr(server, " "'_get_or_create_session', lambda sid, cwd: ))" ) - return _orig_get_or_create(self, session_id, cwd) + # `**kwargs` passes straight through: this wrapper's subject is the session + # *id*, and a call shape it cannot express would break every test that reaches + # the factory for a reason of its own (`register=False`, #2527). + return _orig_get_or_create(self, session_id, cwd, **kwargs) monkeypatch.setattr( daemon_mod.EmrgServer, "_get_or_create_session", _guarded_get_or_create diff --git a/tests/test_agent_md_prompt_cap.py b/tests/test_agent_md_prompt_cap.py index 825232ed..1501cc5e 100644 --- a/tests/test_agent_md_prompt_cap.py +++ b/tests/test_agent_md_prompt_cap.py @@ -39,7 +39,7 @@ def _make_server() -> EmrgServer: """A minimal server; its project log is redirected off the real `~/.emrg`.""" server = EmrgServer(LlmConfig(base_url="http://localhost", api_key="test")) - server._projects_log = Path(tempfile.mkdtemp()) / "agents.yml" + server._agents_log = Path(tempfile.mkdtemp()) / "agents.yml" return server diff --git a/tests/test_agent_registry.py b/tests/test_agent_registry.py new file mode 100644 index 00000000..d429221b --- /dev/null +++ b/tests/test_agent_registry.py @@ -0,0 +1,195 @@ +"""The agent register: one directory, one row, one name — and no normalisation. + +What these pin is the whole of the rule the daemon's older ``_touch_project`` did not +follow: ``path`` is the string the session holds, a directory under another Agent's +``.emrg/`` is that Agent's bookkeeping rather than a workspace, and a name is unique +because a task resolves its directory by name. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from emrg.server.agents import AgentRegistry, _unique_name + + +def _registry(tmp_path: Path) -> AgentRegistry: + return AgentRegistry(tmp_path / "config" / "agents.yml") + + +# ── the path is stored verbatim ───────────────────────────────────────────── + +def test_a_directory_is_stored_exactly_as_the_session_holds_it(tmp_path): + """No realpath, no trailing-slash trim: the row answers ``str(session.cwd)``.""" + reg = _registry(tmp_path) + work = tmp_path / "work" + work.mkdir() + + row = reg.for_dir(str(work)) + + assert row["path"] == str(work) + assert reg.entries()[0]["path"] == str(work) + + +def test_a_symlinked_directory_is_its_own_row(tmp_path): + """The direct evidence that ``realpath`` is gone: two spellings, two rows.""" + reg = _registry(tmp_path) + real = tmp_path / "real" + real.mkdir() + link = tmp_path / "link" + link.symlink_to(real, target_is_directory=True) + + reg.for_dir(str(real)) + reg.for_dir(str(link)) + + paths = {row["path"] for row in reg.entries()} + assert paths == {str(real), str(link)}, ( + "the symlink was collapsed onto its target — that is the normalisation this " + f"register no longer performs: {paths}" + ) + + +# ── one directory, one row ────────────────────────────────────────────────── + +def test_a_second_session_refreshes_the_row_instead_of_adding_one(tmp_path): + reg = _registry(tmp_path) + work = tmp_path / "work" + work.mkdir() + + first = reg.for_dir(str(work)) + second = reg.for_dir(str(work)) + + rows = reg.entries() + assert len(rows) == 1, rows + assert second["last_active"] >= first["last_active"] + assert second["name"] == first["name"] + + +def test_a_subdirectory_of_a_registered_directory_gets_its_own_row(tmp_path): + """The direct evidence that the parent merge is gone. + + It used to refresh the parent's ``last_active`` and write nothing, unless the + subdirectory happened to be a git root — so a package in a monorepo, a clone + inside a larger checkout, and a plain subdirectory were all invisible. + """ + reg = _registry(tmp_path) + parent = tmp_path / "repo" + child = parent / "src" + child.mkdir(parents=True) + + reg.for_dir(str(parent)) + reg.for_dir(str(child)) + + paths = [row["path"] for row in reg.entries()] + assert str(child) in paths, f"the subdirectory was merged into its parent: {paths}" + + +def test_a_directory_under_an_agents_emrg_is_not_registered(tmp_path): + """The sub-session slot and the boss session: that Agent's bookkeeping, not a workspace.""" + reg = _registry(tmp_path) + parent = tmp_path / "repo" + parent.mkdir() + slot = parent / ".emrg" / "sessions" / "sid1" / "sub-agent" / "sub1" + slot.mkdir(parents=True) + + reg.for_dir(str(parent)) + assert reg.for_dir(str(slot)) == {} + + assert [row["path"] for row in reg.entries()] == [str(parent)] + + +def test_the_home_directory_is_not_registered(tmp_path): + """The one path exception kept: a session there would write beside the config.""" + reg = _registry(tmp_path) + + assert reg.for_dir(os.path.expanduser("~")) == {} + assert reg.entries() == [] + + +# ── names are unique ──────────────────────────────────────────────────────── + +def test_two_directories_with_one_basename_get_distinct_names(tmp_path): + """A task resolves its directory *by name*, so a shared name is a misroute.""" + reg = _registry(tmp_path) + first = tmp_path / "a" / "src" + second = tmp_path / "b" / "src" + first.mkdir(parents=True) + second.mkdir(parents=True) + + one = reg.for_dir(str(first)) + two = reg.for_dir(str(second)) + + assert (one["name"], two["name"]) == ("src", "src-2") + by_name = {row["name"]: row["path"] for row in reg.entries()} + assert by_name == {"src": str(first), "src-2": str(second)}, ( + "each name must resolve to its own directory — a task looks its agent up " + f"by name, so a shared one is a silent misroute: {by_name}" + ) + + +def test_the_suffix_is_allocated_against_the_names_present_at_creation(): + assert _unique_name("src", set()) == "src" + assert _unique_name("src", {"src"}) == "src-2" + assert _unique_name("src", {"src", "src-2"}) == "src-3" + assert _unique_name("src", {"src-3"}) == "src" + + +def test_removing_a_row_drops_it_and_frees_its_name(tmp_path): + reg = _registry(tmp_path) + first = tmp_path / "a" / "src" + second = tmp_path / "b" / "src" + first.mkdir(parents=True) + second.mkdir(parents=True) + reg.for_dir(str(first)) + reg.for_dir(str(second)) + + assert reg.remove("src-2") is True + assert [row["name"] for row in reg.entries()] == ["src"] + assert reg.remove("src-2") is False, "a second removal finds nothing to remove" + + +def test_a_register_that_cannot_be_opened_answers_no_decision(tmp_path): + """``None`` is not ``False``: nothing was read, so nothing was decided.""" + reg = _registry(tmp_path) + reg.path.parent.mkdir(parents=True, exist_ok=True) + reg.path.mkdir() # a directory where the register belongs + + assert reg.remove("anything") is None + assert reg.entries() == [] + + +# ── the daemon's wire: registration happens where a session is opened ─────── + +def test_opening_a_session_registers_the_directory(tmp_path): + from emrg.server.daemon import EmrgServer + + server = object.__new__(EmrgServer) + server._agents_log = tmp_path / "config" / "agents.yml" + work = tmp_path / "work" + work.mkdir() + + server._get_or_create_session("sid1", work) + + rows = server._agents.entries() + assert [row["path"] for row in rows] == [str(work)], ( + "registration belongs at session creation, not on an inbound message" + ) + + +def test_the_upgrade_session_registers_nothing(tmp_path): + """It runs in the installation's own work directory, not one a host opened.""" + from emrg.server.daemon import EmrgServer + + server = object.__new__(EmrgServer) + server._agents_log = tmp_path / "config" / "agents.yml" + work = tmp_path / "upgrade-work" / "emrg" + work.mkdir(parents=True) + + # A plain id on purpose: the real `emrg-upgrade` id is refused outright by the + # hermeticity guard, whose subject is that id and not this keyword. + server._get_or_create_session("an-upgrade-run", work, register=False) + + assert server._agents.entries() == [] diff --git a/tests/test_boss_tool.py b/tests/test_boss_tool.py index fb2f7dc5..f748d879 100644 --- a/tests/test_boss_tool.py +++ b/tests/test_boss_tool.py @@ -272,7 +272,7 @@ def _server(tmp_path, monkeypatch) -> EmrgServer: monkeypatch.setattr(daemon_mod, "_PLANTED_FIRE_MARKER_PATH", tmp_path / "fire") monkeypatch.setattr(daemon_mod, "_PLANTED_FIRE_ROUND_COMPLETE_PATH", tmp_path / "rc") server = EmrgServer(LlmConfig(base_url="http://localhost", api_key="test")) - server._projects_log = tmp_path / "agents.yml" + server._agents_log = tmp_path / "agents.yml" server._maybe_reflect_memory = lambda *a, **k: None return server diff --git a/tests/test_daemon.py b/tests/test_daemon.py index ee61ba18..a0d445db 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -167,8 +167,8 @@ def _make_server() -> EmrgServer: server = EmrgServer(LlmConfig(base_url="http://localhost", api_key="test")) # Point the project log at a tmp file by default so context/message tests # can never write the real ~/.emrg/agents.yml (2026-08-13 leak). Tests - # that exercise _projects_log override it explicitly afterwards. - server._projects_log = Path(tempfile.mkdtemp()) / "agents.yml" + # that exercise _agents_log override it explicitly afterwards. + server._agents_log = Path(tempfile.mkdtemp()) / "agents.yml" return server @@ -3194,7 +3194,7 @@ def test_list_projects_includes_evolution_workspace(tmp_path, monkeypatch): "- name: other\n path: /home/u/work/other\n", encoding="utf-8", ) - monkeypatch.setattr(server, "_projects_log", projects_file) + monkeypatch.setattr(server, "_agents_log", projects_file) writer = _FakeWriter() asyncio.run(server._handle_list_projects(writer)) @@ -3211,13 +3211,13 @@ def test_list_projects_includes_evolution_workspace(tmp_path, monkeypatch): def _server_with_projects(tmp_path: Path, entries: list[dict]) -> tuple[EmrgServer, Path]: - """Server whose _projects_log points at a tmp agents.yml with entries.""" + """Server whose _agents_log points at a tmp agents.yml with entries.""" server = _make_server() projects_file = tmp_path / "agents.yml" projects_file.write_text( yaml.safe_dump(entries, allow_unicode=True), encoding="utf-8" ) - server._projects_log = projects_file + server._agents_log = projects_file return server, projects_file @@ -3278,7 +3278,7 @@ def test_remove_project_preserves_disk_session_data(tmp_path): def test_remove_project_no_projects_file(tmp_path): """No agents.yml → removed=False without raising.""" server = _make_server() - server._projects_log = tmp_path / "agents.yml" # never created + server._agents_log = tmp_path / "agents.yml" # never created writer = _FakeWriter() asyncio.run(server._handle_remove_project("alpha", writer)) assert _decode_frames(writer)[0] == { @@ -3291,7 +3291,7 @@ def test_remove_project_corrupt_yaml_reports_error(tmp_path): server = _make_server() projects_file = tmp_path / "agents.yml" projects_file.write_text("a: [unclosed", encoding="utf-8") - server._projects_log = projects_file + server._agents_log = projects_file writer = _FakeWriter() asyncio.run(server._handle_remove_project("alpha", writer)) replies = _decode_frames(writer) @@ -3338,7 +3338,7 @@ def test_list_projects_ordered_by_latest_session_activity(tmp_path, monkeypatch) f"- name: nosess\n path: {p_none}\n", encoding="utf-8", ) - monkeypatch.setattr(server, "_projects_log", projects_file) + monkeypatch.setattr(server, "_agents_log", projects_file) writer = _FakeWriter() asyncio.run(server._handle_list_projects(writer)) @@ -4987,17 +4987,18 @@ def test_the_rant_panel_survives_a_legacy_array_row_and_a_bare_scalar(tmp_path): # ── agents.yml: the registry every client reads first ───────────── # # These are the daemon's four readers of one file, `~/.emrg/agents.yml`, and each of -# them runs on the daemon's **event loop**: the startup backfill, the per-message project -# touch, `list_projects` — which every client sends on connect, so it is the first frame -# a GUI or TUI ever gets an answer to — and `remove_project`. A FIFO at that path -# therefore does not fail a request, it stops the daemon answering anyone again, which is -# the condition issue #2062 named for the three file tools and #2073 for the daemon's own -# readers. Measured 2026-10-11 (`cyc20261011-173122`) on master `60541e39`: each of the -# four calls below **did not return** under a FIFO, while the pipe-free controls ran. +# them runs on the daemon's **event loop**: the startup backfill, the register write that +# opening a session makes, `list_projects` — which every client sends on connect, so it is +# the first frame a GUI or TUI ever gets an answer to — and `remove_project`. A FIFO at +# that path therefore does not fail a request, it stops the daemon answering anyone again, +# which is the condition issue #2062 named for the three file tools and #2073 for the +# daemon's own readers. Measured 2026-10-11 (`cyc20261011-173122`) on master `60541e39`: +# each of the four calls below **did not return** under a FIFO, while the pipe-free +# controls ran. # # Two shapes, and the difference is deliberate: a **reader** answers the refusal through # the `except OSError` it already has for a registry it cannot read (so no new branch is -# added), while `_touch_project` — which reads the registry and then **rewrites** it — +# added), while `AgentRegistry.for_dir` — which reads the registry and then **rewrites** it — # skips the registration instead of falling through to its "rebuilding" branch, because # that branch would answer a host file it could not read with an *empty* registry and # delete every project the host had registered. @@ -5016,7 +5017,7 @@ def test_a_named_pipe_registry_does_not_stop_the_project_listing(tmp_path, monke server = _make_server() fifo = tmp_path / "agents.yml" os.mkfifo(fifo) - monkeypatch.setattr(server, "_projects_log", fifo) + monkeypatch.setattr(server, "_agents_log", fifo) writer = _FakeWriter() within(DEADLINE_SECONDS, lambda: asyncio.run(server._handle_list_projects(writer))) @@ -5032,7 +5033,7 @@ def test_a_directory_registry_is_refused_by_its_kind_in_the_listing( server = _make_server() registry = tmp_path / "agents.yml" registry.mkdir() - monkeypatch.setattr(server, "_projects_log", registry) + monkeypatch.setattr(server, "_agents_log", registry) writer = _FakeWriter() with caplog.at_level(logging.ERROR): @@ -5045,7 +5046,7 @@ def test_a_directory_registry_is_refused_by_its_kind_in_the_listing( @_needs_mkfifo -def test_a_named_pipe_registry_is_neither_read_nor_rewritten_by_touch_project( +def test_a_named_pipe_registry_is_neither_read_nor_rewritten_by_the_register( tmp_path, monkeypatch): """The read-then-write site: skipped, and the registry is left as it was.""" import stat @@ -5053,25 +5054,25 @@ def test_a_named_pipe_registry_is_neither_read_nor_rewritten_by_touch_project( server = _make_server() fifo = tmp_path / "agents.yml" os.mkfifo(fifo) - monkeypatch.setattr(server, "_projects_log", fifo) + monkeypatch.setattr(server, "_agents_log", fifo) - within(DEADLINE_SECONDS, lambda: server._touch_project(str(tmp_path))) + within(DEADLINE_SECONDS, lambda: server._agents.for_dir(str(tmp_path))) assert stat.S_ISFIFO(os.stat(fifo).st_mode), ( "the registry was rewritten — a read that answered 'no entries' would have " "deleted every project the host registered") -def test_a_directory_registry_warns_touch_project_and_keeps_it( +def test_a_directory_registry_warns_the_register_and_keeps_it( tmp_path, monkeypatch, caplog): """The pipe-free half: the warning names the kind, and no write replaces it.""" server = _make_server() registry = tmp_path / "agents.yml" registry.mkdir() - monkeypatch.setattr(server, "_projects_log", registry) + monkeypatch.setattr(server, "_agents_log", registry) with caplog.at_level(logging.WARNING): - server._touch_project(str(tmp_path)) + server._agents.for_dir(str(tmp_path)) assert registry.is_dir(), "the registry was replaced by the write this guard prevents" assert "not a regular file" in caplog.text, ( @@ -5086,7 +5087,7 @@ def test_a_named_pipe_registry_does_not_stop_the_index_backfill(tmp_path, monkey server = _make_server() fifo = tmp_path / "agents.yml" os.mkfifo(fifo) - monkeypatch.setattr(server, "_projects_log", fifo) + monkeypatch.setattr(server, "_agents_log", fifo) seen: dict[str, list[str]] = {} monkeypatch.setattr( sessions_index_mod, "rebuild_sessions_index", @@ -5107,7 +5108,7 @@ def test_a_directory_registry_is_refused_by_its_kind_in_the_backfill( server = _make_server() registry = tmp_path / "agents.yml" registry.mkdir() - monkeypatch.setattr(server, "_projects_log", registry) + monkeypatch.setattr(server, "_agents_log", registry) monkeypatch.setattr( sessions_index_mod, "rebuild_sessions_index", lambda cfg, paths: 0) @@ -5123,7 +5124,7 @@ def test_a_named_pipe_registry_does_not_stop_remove_project(tmp_path): server = _make_server() fifo = tmp_path / "agents.yml" os.mkfifo(fifo) - server._projects_log = fifo + server._agents_log = fifo writer = _FakeWriter() within(DEADLINE_SECONDS, @@ -5141,7 +5142,7 @@ def test_a_directory_registry_is_refused_by_its_kind_in_remove_project( server = _make_server() registry = tmp_path / "agents.yml" registry.mkdir() - monkeypatch.setattr(server, "_projects_log", registry) + monkeypatch.setattr(server, "_agents_log", registry) writer = _FakeWriter() with caplog.at_level(logging.ERROR): diff --git a/tests/test_hermeticity_guard.py b/tests/test_hermeticity_guard.py index 13b976e6..b52d58c7 100644 --- a/tests/test_hermeticity_guard.py +++ b/tests/test_hermeticity_guard.py @@ -17,13 +17,18 @@ import pytest -def test_guard_installed_in_both_modules(): - """Both scheduler and daemon carry the guarded atomic_write_yaml.""" - import emrg.server.daemon as daemon_mod +def test_guard_is_installed_in_every_module_that_writes_a_config_file(): + """The scheduler and the agent register both carry the guarded writer. + + Named per module rather than "both": the set moved when the register's only writer + became ``agents.py`` (#2527), and a guard that names a module which no longer + imports the function stops covering the one that does. + """ + import emrg.server.agents as agents_mod import emrg.server.scheduler as sched_mod wrapped = sched_mod.atomic_write_yaml - assert wrapped is daemon_mod.atomic_write_yaml + assert wrapped is agents_mod.atomic_write_yaml assert wrapped.__name__ == "guarded" diff --git a/tests/test_local_exclude.py b/tests/test_local_exclude.py index 6ee8dbbe..8679bf04 100644 --- a/tests/test_local_exclude.py +++ b/tests/test_local_exclude.py @@ -272,11 +272,11 @@ def test_real_dirt_still_triggers_the_guard(tmp_path): def test_registering_a_project_writes_the_exclude_too(tmp_path): - """The daemon-side call site: `_touch_project` is where this instance takes a + """The daemon-side call site: `AgentRegistry.for_dir` is where this instance takes a directory on as a project, so a later session in it never starts from a tree whose dirt is EMRG's own bookkeeping. - Built with `object.__new__` on purpose — `_touch_project` reads one path + Built with `object.__new__` on purpose — `for_dir` reads one path attribute and writes one file, and a test of it has no business constructing (let alone starting) a server. """ @@ -286,9 +286,9 @@ def test_registering_a_project_writes_the_exclude_too(tmp_path): _runtime_dir(repo) assert TaskHandler._is_dirty_tree_sync(str(repo)) is True, "precondition: real dirt" server = object.__new__(EmrgServer) - server._projects_log = tmp_path / "config" / "agents.yml" + server._agents_log = tmp_path / "config" / "agents.yml" - server._touch_project(str(repo)) + server._agents.for_dir(str(repo)) assert EXCLUDE_ENTRY in _exclude_file(repo).read_text(encoding="utf-8") assert TaskHandler._is_dirty_tree_sync(str(repo)) is False @@ -297,7 +297,7 @@ def test_registering_a_project_writes_the_exclude_too(tmp_path): def test_a_nested_project_directory_learns_to_ignore_its_own_runtime_dir(tmp_path): """The daemon-side call site, one directory shape over (issue #1527). - `_touch_project` used to call the exclude helper only when `/.git` + `AgentRegistry.for_dir` used to call the exclude helper only when `/.git` existed — the *root* question, `repo_scope`'s other answer. A project registered at a directory inside a repository (a package in a monorepo, this instance's own `work/` tree, a clone nested in a larger checkout) therefore @@ -327,8 +327,8 @@ def test_a_nested_project_directory_learns_to_ignore_its_own_runtime_dir(tmp_pat assert TaskHandler._is_dirty_tree_sync(str(nested)) is True, "precondition: real dirt" server = object.__new__(EmrgServer) - server._projects_log = tmp_path / "config" / "agents.yml" - server._touch_project(str(nested)) + server._agents_log = tmp_path / "config" / "agents.yml" + server._agents.for_dir(str(nested)) entries = _exclude_file(repo).read_text(encoding="utf-8").splitlines() assert "/work/clone/.emrg/" in entries, entries @@ -336,7 +336,7 @@ def test_a_nested_project_directory_learns_to_ignore_its_own_runtime_dir(tmp_pat assert TaskHandler._is_dirty_tree_sync(str(nested)) is False -def test_touch_project_writes_nothing_for_a_directory_in_no_repository(tmp_path, caplog): +def test_the_register_writes_nothing_for_a_directory_in_no_repository(tmp_path, caplog): """The same deletion's other direction (issue #1527). With the marker gate gone, the callee's own refusal is the guard: a directory @@ -351,9 +351,9 @@ def test_touch_project_writes_nothing_for_a_directory_in_no_repository(tmp_path, before = sorted(p.name for p in plain.iterdir()) server = object.__new__(EmrgServer) - server._projects_log = tmp_path / "config" / "agents.yml" + server._agents_log = tmp_path / "config" / "agents.yml" with caplog.at_level("INFO"): - server._touch_project(str(plain)) + server._agents.for_dir(str(plain)) assert sorted(p.name for p in plain.iterdir()) == before assert not (plain / ".git").exists(), "no git dir is conjured into a plain directory" diff --git a/tests/test_memory_block_leaves_the_prefix.py b/tests/test_memory_block_leaves_the_prefix.py index b04a536c..31910e44 100644 --- a/tests/test_memory_block_leaves_the_prefix.py +++ b/tests/test_memory_block_leaves_the_prefix.py @@ -50,7 +50,7 @@ def _make_server() -> EmrgServer: reason: building a system prompt must never write the host's `~/.emrg/agents.yml`. """ server = EmrgServer(LlmConfig(base_url="http://localhost", api_key="test")) - server._projects_log = Path(tempfile.mkdtemp()) / "agents.yml" + server._agents_log = Path(tempfile.mkdtemp()) / "agents.yml" return server diff --git a/tests/test_memory_row_bound_reach.py b/tests/test_memory_row_bound_reach.py index edf7bb0e..ccc22e24 100644 --- a/tests/test_memory_row_bound_reach.py +++ b/tests/test_memory_row_bound_reach.py @@ -102,7 +102,7 @@ def _make_server() -> EmrgServer: system prompt must never write the host's `~/.emrg/agents.yml`. """ server = EmrgServer(LlmConfig(base_url="http://localhost", api_key="test")) - server._projects_log = Path(tempfile.mkdtemp()) / "agents.yml" + server._agents_log = Path(tempfile.mkdtemp()) / "agents.yml" return server diff --git a/tests/test_message_time_is_the_daemons.py b/tests/test_message_time_is_the_daemons.py index 923543c2..f06d0a4a 100644 --- a/tests/test_message_time_is_the_daemons.py +++ b/tests/test_message_time_is_the_daemons.py @@ -61,7 +61,7 @@ def _ctx(width: int = 80): def _make_server() -> EmrgServer: server = EmrgServer(LlmConfig(base_url="http://localhost", api_key="test")) - server._projects_log = Path(tempfile.mkdtemp()) / "agents.yml" + server._agents_log = Path(tempfile.mkdtemp()) / "agents.yml" return server diff --git a/tests/test_project_context_paths.py b/tests/test_project_context_paths.py index ebbcbf75..e8e8577d 100644 --- a/tests/test_project_context_paths.py +++ b/tests/test_project_context_paths.py @@ -90,7 +90,7 @@ def test_the_collector_embeds_exactly_the_files_the_constant_names(tmp_path: Pat be "every file in the directory". """ server = EmrgServer(LlmConfig(base_url="http://localhost", api_key="test")) - server._projects_log = Path(tempfile.mkdtemp()) / "agents.yml" + server._agents_log = Path(tempfile.mkdtemp()) / "agents.yml" for name in PROJECT_CONTEXT_FILES: (tmp_path / name).write_text(f"# {name}\n", encoding="utf-8") (tmp_path / "NOTES.md").write_text("# not in the embedded set\n", encoding="utf-8") diff --git a/tests/test_prompt_templates.py b/tests/test_prompt_templates.py index 50275b6b..c8fb1081 100644 --- a/tests/test_prompt_templates.py +++ b/tests/test_prompt_templates.py @@ -351,7 +351,7 @@ def test_the_journal_prompt_takes_its_identity_from_the_task_config( # template that acted on it ("every 3-5 rounds"), and 50 is divisible by 5, so the # rule fired every round. # * `evolution_cwd` — the definition `~/.emrg/evolution`, which `_ensure_self_evolution_task` -# and `_touch_project` now spell out at their own call sites. +# and `AgentRegistry.for_dir` now spell out at their own call sites. # # `_placeholder_names` reads the two shapes a template can name a context key in: an # expression (`{{ x }}`, filters and attribute access included — only the root name is diff --git a/tests/test_pwsh_tool_v2.py b/tests/test_pwsh_tool_v2.py index ec748194..9beae53d 100644 --- a/tests/test_pwsh_tool_v2.py +++ b/tests/test_pwsh_tool_v2.py @@ -69,7 +69,7 @@ def _no_ambient_switch(monkeypatch): def _instantiate() -> EmrgServer: """Build a server the way the daemon does, then keep its logs out of the host.""" server = EmrgServer(LlmConfig(base_url="http://localhost", api_key="test")) - server._projects_log = Path(tempfile.mkdtemp()) / "agents.yml" + server._agents_log = Path(tempfile.mkdtemp()) / "agents.yml" return server diff --git a/tests/test_redaction.py b/tests/test_redaction.py index c47cb6e8..cf00258a 100644 --- a/tests/test_redaction.py +++ b/tests/test_redaction.py @@ -240,7 +240,7 @@ def test_the_pwsh_argv_line_masks_whatever_element_it_prints(monkeypatch, tmp_pa def _make_server(tmp_path: Path) -> EmrgServer: server = EmrgServer(LlmConfig(base_url="http://localhost", api_key="test")) - server._projects_log = tmp_path / "agents.yml" + server._agents_log = tmp_path / "agents.yml" return server diff --git a/tests/test_sandbox_escalation.py b/tests/test_sandbox_escalation.py index 08b88884..a00a81b6 100644 --- a/tests/test_sandbox_escalation.py +++ b/tests/test_sandbox_escalation.py @@ -76,7 +76,7 @@ async def execute(self, arguments: dict) -> ToolResult: def _server() -> EmrgServer: server = EmrgServer(LlmConfig(base_url="http://localhost", api_key="test")) - server._projects_log = Path(tempfile.mkdtemp()) / "agents.yml" + server._agents_log = Path(tempfile.mkdtemp()) / "agents.yml" return server diff --git a/tests/test_shell_tool_mount.py b/tests/test_shell_tool_mount.py index 8264f906..fe130aad 100644 --- a/tests/test_shell_tool_mount.py +++ b/tests/test_shell_tool_mount.py @@ -47,7 +47,7 @@ def _instantiate() -> EmrgServer: from emrg.config import LlmConfig server = EmrgServer(LlmConfig(base_url="http://localhost", api_key="test")) - server._projects_log = Path(tempfile.mkdtemp()) / "agents.yml" + server._agents_log = Path(tempfile.mkdtemp()) / "agents.yml" return server diff --git a/tests/test_skills_read_live.py b/tests/test_skills_read_live.py index e8ab2fbf..01052d06 100644 --- a/tests/test_skills_read_live.py +++ b/tests/test_skills_read_live.py @@ -176,7 +176,7 @@ def _server(tmp_path: Path) -> EmrgServer: import tempfile server = EmrgServer(LlmConfig(base_url="http://localhost", api_key="test")) - server._projects_log = Path(tempfile.mkdtemp()) / "agents.yml" + server._agents_log = Path(tempfile.mkdtemp()) / "agents.yml" return server diff --git a/tests/test_subagent_tool.py b/tests/test_subagent_tool.py index 0704def8..f49da5d6 100644 --- a/tests/test_subagent_tool.py +++ b/tests/test_subagent_tool.py @@ -64,7 +64,7 @@ def _server(tmp_path, monkeypatch) -> EmrgServer: monkeypatch.setattr(daemon_mod, "_PLANTED_FIRE_ROUND_COMPLETE_PATH", tmp_path / "planted-fire-round-complete") server = EmrgServer(LlmConfig(base_url="http://localhost", api_key="test")) - server._projects_log = Path(tempfile.mkdtemp()) / "agents.yml" + server._agents_log = Path(tempfile.mkdtemp()) / "agents.yml" # `_broadcast` is left real: with no connections it sends nowhere, and it is the # one place that records a turn's terminal frame. Stubbing it made every child # turn report "ended without a terminal frame", which is the stub talking. diff --git a/tests/test_tool_argument_domains.py b/tests/test_tool_argument_domains.py index 0a3813b1..4fb02edb 100644 --- a/tests/test_tool_argument_domains.py +++ b/tests/test_tool_argument_domains.py @@ -446,7 +446,7 @@ def test_the_sweeps_subject_is_the_registered_tool_set(tmp_path): # `tests/test_shell_tool_mount.py::_instantiate` makes so construction writes nothing # of the host's. No daemon is started and nothing is torn down — this is an object. server = EmrgServer(LlmConfig(base_url="http://localhost", api_key="test")) - server._projects_log = tmp_path / "agents.yml" + server._agents_log = tmp_path / "agents.yml" listed = {t.definition().name for t in TOOL_SET} registered = set(server.tools.names) diff --git a/tests/test_tool_call_batch_label.py b/tests/test_tool_call_batch_label.py index 2e10bab2..cae193bd 100644 --- a/tests/test_tool_call_batch_label.py +++ b/tests/test_tool_call_batch_label.py @@ -57,7 +57,7 @@ def _ctx(width: int = 100) -> RenderContext: def _make_server() -> EmrgServer: server = EmrgServer(LlmConfig(base_url="http://localhost", api_key="test")) - server._projects_log = Path(tempfile_dir()) / "agents.yml" + server._agents_log = Path(tempfile_dir()) / "agents.yml" return server diff --git a/tests/test_tool_set_per_message.py b/tests/test_tool_set_per_message.py index 5b4d7ba5..d45b6987 100644 --- a/tests/test_tool_set_per_message.py +++ b/tests/test_tool_set_per_message.py @@ -64,7 +64,7 @@ def _registry(platform_name: str | None = None): def _server(tmp_path) -> EmrgServer: server = EmrgServer(LlmConfig(base_url="http://localhost", api_key="test")) - server._projects_log = tmp_path / "agents.yml" + server._agents_log = tmp_path / "agents.yml" return server diff --git a/tests/test_turn_context_is_persisted.py b/tests/test_turn_context_is_persisted.py index b838eed7..afc7352f 100644 --- a/tests/test_turn_context_is_persisted.py +++ b/tests/test_turn_context_is_persisted.py @@ -45,7 +45,7 @@ def _make_server() -> EmrgServer: """A minimal server with the projects log pointed away from the host's.""" server = EmrgServer(LlmConfig(base_url="http://localhost", api_key="test")) - server._projects_log = Path(tempfile.mkdtemp()) / "agents.yml" + server._agents_log = Path(tempfile.mkdtemp()) / "agents.yml" return server diff --git a/tests/test_turn_terminal_frame.py b/tests/test_turn_terminal_frame.py index ce7321fa..003913c0 100644 --- a/tests/test_turn_terminal_frame.py +++ b/tests/test_turn_terminal_frame.py @@ -46,7 +46,7 @@ def _server() -> EmrgServer: # ~/.emrg/agents.yml (the 2026-08-13 leak). import tempfile # noqa: PLC0415 — local to the fixture helper - server._projects_log = Path(tempfile.mkdtemp()) / "agents.yml" + server._agents_log = Path(tempfile.mkdtemp()) / "agents.yml" return server diff --git a/tests/test_upgrade.py b/tests/test_upgrade.py index 46e4adea..9d8e29fe 100644 --- a/tests/test_upgrade.py +++ b/tests/test_upgrade.py @@ -533,7 +533,7 @@ def test_daemon_upgrade_session_runner(monkeypatch, tmp_path): # ⛔ Red line (host 2026-08-21T10:35:57): tests must never create/write the # real emrg-upgrade session — isolate the session factory (the conftest # autouse guard raises on the real one for SESSION_ID). - monkeypatch.setattr(server, "_get_or_create_session", lambda sid, cwd: object()) + monkeypatch.setattr(server, "_get_or_create_session", lambda sid, cwd, **kwargs: object()) ran = [] async def fake_loop(req, ws, session, cancel_event, allow_tools=True): @@ -574,7 +574,7 @@ def test_daemon_upgrade_session_is_reachable_by_a_clients_cancel(monkeypatch, tm from tests.test_daemon import _make_server server = _make_server() - monkeypatch.setattr(server, "_get_or_create_session", lambda sid, cwd: object()) + monkeypatch.setattr(server, "_get_or_create_session", lambda sid, cwd, **kwargs: object()) seen: dict[str, object] = {} async def fake_loop(req, ws, session, cancel_event, allow_tools=True): @@ -613,7 +613,7 @@ def test_daemon_upgrade_session_publishes_no_turn_task(monkeypatch, tmp_path): from tests.test_daemon import _make_server server = _make_server() - monkeypatch.setattr(server, "_get_or_create_session", lambda sid, cwd: object()) + monkeypatch.setattr(server, "_get_or_create_session", lambda sid, cwd, **kwargs: object()) seen: dict[str, object] = {} async def fake_loop(req, ws, session, cancel_event, allow_tools=True): From b06ffbfa103a4fe1ca59a9f6835dce9ccc85d4bb Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Fri, 23 Oct 2026 15:35:07 +0800 Subject: [PATCH 2/2] emrg: reclaim a dead row's name so one directory keeps one row `AgentRegistry.upsert` moved a row by name and never read the `path` column, so the scheduler's self-heal could leave two rows naming one directory: a session opening the evolution workspace got `emrg-2` because the dead `emrg` row still held the name, and the heal then moved that dead row onto the directory the new row already named. Two halves, at the origin and as a guarantee: - `for_dir` reserves a name only while its directory exists. A name held by a row whose path is gone is taken over, and that row goes with it. The heal repairs entries *by name*, so the name must come back onto a directory rather than be suffixed beside a dead one. - `upsert` drops any other row naming the path it just wrote, so one directory is one row however the method is called. The dropped row's name is logged, because a task may have been resolving through it. Nothing live is renamed: a living row keeps its name, its path and its suffixing order, and `upsert`'s name still decides which row moves. --- emrg/server/agents.py | 62 +++++++++++++++++++----- tests/test_agent_registry.py | 92 ++++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+), 12 deletions(-) diff --git a/emrg/server/agents.py b/emrg/server/agents.py index c0146cbe..724b65e9 100644 --- a/emrg/server/agents.py +++ b/emrg/server/agents.py @@ -28,6 +28,15 @@ the names present at that moment; it is not part of the row's identity, and removing a row frees its name. +**A name is reserved only while its directory is there.** A basename held by a row whose +``path`` is gone does not push the next arrival onto ``-2``: that name is taken over, +and the dead row goes with it. Both halves matter. The scheduler repairs a stale entry +*by name*, so a task may still be resolving through the dead row's name — taking the +name over is what puts it back on a directory. Suffixing beside it instead is what left +**two rows on one path**: the repair then moved the dead row onto the directory the new +row had already been given ``-2`` for. Two rows for one directory is never a legal +state, and ``upsert`` cannot write one however it is called. + Two directories are not registered, and each for a stated reason rather than a guess: * **the home directory** — a session opened there would put its own session directory @@ -223,10 +232,22 @@ def for_dir(self, cwd: str | os.PathLike[str]) -> dict: base, ) - name = _unique_name( - os.path.basename(base.rstrip(os.sep)) or base, - {row.get("name") for row in rows if row.get("name")}, - ) + # A name is reserved only while the directory holding it is there. A row pointing + # at a path that is gone is the 2026-08-12 shape, and the scheduler repairs it + # *by name* — so a task may still be resolving through that name, and taking the + # name over is what puts it back on a directory. Appending `-2` beside it instead + # is what left two rows on one path: the repair then moved the dead row onto the + # directory the new row already named. + living = { + row.get("name") + for row in rows + if row.get("name") and os.path.isdir(row.get("path") or "") + } + name = _unique_name(os.path.basename(base.rstrip(os.sep)) or base, living) + # The name is this row's now: whatever still holds it points at a directory that + # is gone (a living holder would have been in `living`), so that row is the one + # the name is taken from. + rows = [row for row in rows if row.get("name") != name] row = {"path": base, "name": name, "last_active": now} rows.append(row) self._write(rows) @@ -239,19 +260,36 @@ def upsert(self, name: str, path: str) -> dict: For a caller that owns a row by name rather than by directory — the scheduler's self-heal, which keeps an ``emrg`` row pointing at the evolution workspace. It writes through this module so the file keeps one writer and one temp prefix. + + The name decides which row **moves**; the path decides what may survive beside + it. A row already naming ``path`` is the same directory said twice, so it goes: + one directory, one row. That state was reachable — the heal moved a stale + ``emrg`` row onto a directory a second row had been given ``-2`` for — and the + reachable half is closed at its origin, by ``for_dir`` taking a dead row's name + over rather than suffixing beside it. What is left here is the guarantee, for a + register a hand-edit or an older file left in that shape. """ rows = self._read() if rows is None: return {} now = datetime.now().isoformat() - for row in rows: - if row.get("name") == name: - row["path"] = str(path) - row["last_active"] = now - self._write(rows) - return row - row = {"path": str(path), "name": name, "last_active": now} - rows.append(row) + target = str(path) + row = next((r for r in rows if r.get("name") == name), None) + if row is None: + row = {"path": target, "name": name, "last_active": now} + rows.append(row) + else: + row["path"] = target + row["last_active"] = now + # A dropped row may be the one a task was resolving through, so its name is kept + # in the log: after this the task finds nothing, and that is what a reader of the + # log needs to be able to see. + for other in [r for r in rows if r is not row and r.get("path") == target]: + logger.warning( + "register: dropping row %r, which names %s as well — upsert(%r) leaves " + "one row per directory", other.get("name"), target, name, + ) + rows.remove(other) self._write(rows) return row diff --git a/tests/test_agent_registry.py b/tests/test_agent_registry.py index d429221b..e7996d09 100644 --- a/tests/test_agent_registry.py +++ b/tests/test_agent_registry.py @@ -151,6 +151,98 @@ def test_removing_a_row_drops_it_and_frees_its_name(tmp_path): assert reg.remove("src-2") is False, "a second removal finds nothing to remove" +# ── a dead row does not hold its name ─────────────────────────────────────── + +def test_a_dead_rows_name_is_taken_over_instead_of_getting_a_suffix(tmp_path): + """A row whose directory is gone does not reserve its name for ever.""" + reg = _registry(tmp_path) + leaked = tmp_path / "leaked" / "src" + leaked.mkdir(parents=True) + assert reg.for_dir(str(leaked))["name"] == "src" + leaked.rmdir() # the 2026-08-12 shape: the row outlives its directory + + real = tmp_path / "real" / "src" + real.mkdir(parents=True) + row = reg.for_dir(str(real)) + + assert row["name"] == "src", ( + "the scheduler repairs an entry *by name*, so a live directory suffixed to " + "`src-2` beside a dead `src` row is what let the repair move the dead row onto " + "the directory the new row already named" + ) + assert [(r["name"], r["path"]) for r in reg.entries()] == [("src", str(real))], ( + "taking the name over drops the dead row: one name, one row" + ) + + +def test_a_dead_row_does_not_cost_a_living_one_its_name(tmp_path): + """Both rows once held `src`; only the one whose directory is gone gives way.""" + reg = _registry(tmp_path) + reg.path.parent.mkdir(parents=True, exist_ok=True) + living = tmp_path / "living" / "src" + living.mkdir(parents=True) + gone = tmp_path / "gone" / "src" + reg.path.write_text( + f"- path: {gone}\n name: src\n" + f"- path: {living}\n name: src-2\n", + encoding="utf-8", + ) + + row = reg.for_dir(str(tmp_path / "fresh" / "src")) + + assert row["name"] == "src", "the dead holder is the one the name is taken from" + assert {r["name"]: r["path"] for r in reg.entries()} == { + "src": str(tmp_path / "fresh" / "src"), + "src-2": str(living), + }, "a living row keeps its name and its path; nothing was suffixed to `src-3`" + + +# ── one directory, one row ────────────────────────────────────────────────── + +def test_the_stale_row_a_registration_and_the_heal_leave_one_row(tmp_path): + """The three steps the daemon takes, which used to end in two rows on one path.""" + reg = _registry(tmp_path) + leaked = tmp_path / "leaked-pytest-dir" / "emrg" + leaked.mkdir(parents=True) + assert reg.for_dir(str(leaked))["name"] == "emrg" + leaked.rmdir() # 1. a stale `emrg` row, the incident self-heal exists for + + workspace = tmp_path / "evolution" / "emrg" + workspace.mkdir(parents=True) + assert reg.for_dir(str(workspace))["name"] == "emrg", ( + "2. a session opens the workspace — it starts at `emrg`, not at `emrg-2`" + ) + + reg.upsert("emrg", str(workspace)) # 3. the scheduler's self-heal, every tick + + rows = reg.entries() + assert [r["path"] for r in rows] == [str(workspace)], ( + "one directory, one row: two rows for one path is what rendered the workspace " + f"twice in the pickers: {[(r['name'], r['path']) for r in rows]}" + ) + assert [r["name"] for r in rows] == ["emrg"] + + +def test_upsert_cannot_leave_two_rows_on_one_path(tmp_path): + """Whatever a caller passes, the register comes out with one row per directory.""" + reg = _registry(tmp_path) + one = tmp_path / "one" + two = tmp_path / "two" + one.mkdir() + two.mkdir() + reg.for_dir(str(one)) + reg.for_dir(str(two)) + + reg.upsert("one", str(two)) # the row `one` moves onto the path row `two` holds + assert [r["path"] for r in reg.entries()] == [str(two)], ( + "a directory is registered as itself: the second row for it must go" + ) + assert [r["name"] for r in reg.entries()] == ["one"] + + reg.upsert("fresh", str(two)) # no row named `fresh` yet: the creating branch + assert [(r["name"], r["path"]) for r in reg.entries()] == [("fresh", str(two))] + + def test_a_register_that_cannot_be_opened_answers_no_decision(tmp_path): """``None`` is not ``False``: nothing was read, so nothing was decided.""" reg = _registry(tmp_path)