diff --git a/emrg/__main__.py b/emrg/__main__.py index 18857934..3882670a 100644 --- a/emrg/__main__.py +++ b/emrg/__main__.py @@ -108,7 +108,7 @@ def _build_parser() -> argparse.ArgumentParser: ) rant_parser.add_argument( "-p", "--project", - help="Target project (from projects.yml). Omit for emrg itself.", + help="Target project (from agents.yml). Omit for emrg itself.", default=None, ) rant_parser.add_argument( diff --git a/emrg/client/widgets.py b/emrg/client/widgets.py index 00e38056..7361e9f7 100644 --- a/emrg/client/widgets.py +++ b/emrg/client/widgets.py @@ -660,7 +660,7 @@ def _format_tool_input(arguments: dict) -> list[str]: class ProjectSelector(Widget): """Interactive project picker — arrow-key navigation with highlight. - Renders a list of projects from projects.yml with the selected one in + Renders a list of projects from agents.yml with the selected one in reverse video. Used by /rant when invoked without @project. """ diff --git a/emrg/config.py b/emrg/config.py index fe661ac1..1064cbfa 100644 --- a/emrg/config.py +++ b/emrg/config.py @@ -221,6 +221,66 @@ def logs_dir() -> Path: return config_dir() / "logs" +#: The register's name before an Agent became the record of the directory a +#: session runs in (#2521). Spelled here and nowhere else in the package: the +#: move below is what retires it. +_LEGACY_AGENTS_NAME = "projects.yml" + + +def agents_file(base: Path | None = None) -> Path: + """Returns the path of the agent registry (`~/.emrg/agents.yml`). + + **The one derivation of that path** (#2521): every reader and writer of + the register asks this rather than spelling `config_dir() / ` again — + the daemon, the scheduler, the rant tool's candidate-name reader, the + sandbox's protected-files list and `scripts/check-memory-rotation.py` — + which is the treatment :func:`logs_dir` already gives the log directory. + + A pure path: asking where the register lives opens and moves nothing. + :func:`migrate_legacy_registry` is the one call that moves it, from the one + process that owns it. + + ``base`` is the caller's own data directory, for a reader whose directory + is not the host's (the rant tool's tests point it at a temp directory). + """ + directory = config_dir() if base is None else base + return directory / "agents.yml" + + +def migrate_legacy_registry(base: Path | None = None) -> bool: + """Move a register still at the pre-rename name onto the new one. + + Returns whether this call moved it. ``Path.rename`` within one directory is + a single filesystem operation, so the register is wholly at the old name or + wholly at the new one and never half of each: the count of entries, every + field and ``last_active`` travel with the file because nothing is read or + rewritten to carry them. + + Called from the daemon's startup and nowhere else — the one process that + owns the register — and not from :func:`agents_file`, so that asking for + the path never mutates a host's state. A test suite constructs the objects + that read the register but never runs that startup, which is exactly the + separation that keeps a run of the suite from renaming the host's file + under a daemon that is still reading the old name. + + Nothing moves once the new name exists (an older daemon that re-created the + legacy file leaves both, and the new name governs), a register that never + existed is not created, and a move that cannot happen — a read-only home, a + permission boundary, a concurrent caller that got there first — answers + ``False`` and leaves the legacy file exactly where it is. + """ + directory = config_dir() if base is None else base + target = agents_file(directory) + legacy = directory / _LEGACY_AGENTS_NAME + try: + if target.exists() or not legacy.exists(): + return False + legacy.rename(target) + return True + except OSError: + return False + + def config_path() -> Path: """Returns the config file path.""" home = Path.home() diff --git a/emrg/gui/main.js b/emrg/gui/main.js index a42e65b5..46e17301 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -908,7 +908,7 @@ vision = false return { ok: true, path: p }; }); - // P5 slice 2:删除项目 = 只删 projects.yml 条目(保留磁盘数据)+ 关闭该项目已打开会话 + // P5 slice 2:删除项目 = 只删 agents.yml 条目(保留磁盘数据)+ 关闭该项目已打开会话 // 受保护项目不可删除(内置 project emrg / 内置 task emrg-task → 演化依赖,删了悬空); // `.emrg` 是 _touch_project 历史记录**非内置 → 可删**(再次访问重新注册)。 // 返回 { ok, removed, closed: [sids] };renderer 收到后负责切换激活会话。 diff --git a/emrg/server/__main__.py b/emrg/server/__main__.py index 34883b38..58c05567 100644 --- a/emrg/server/__main__.py +++ b/emrg/server/__main__.py @@ -5,7 +5,7 @@ import traceback from logging.handlers import RotatingFileHandler -from emrg.config import logs_dir +from emrg.config import logs_dir, migrate_legacy_registry from emrg.logfiles import migrate_legacy_logs from emrg.server.daemon import DaemonExit, _non_regular_kind, run_server from emrg.server.logcontext import session_label @@ -195,6 +195,13 @@ def main() -> None: # whatever the crash log keeps as the low-level sink. try: config = load_config() + # The agent register's move from its pre-rename name runs here and only here + # (#2521): this is the one process that owns the file, and it runs before the + # server object below asks `emrg.config.agents_file()` for the path. It is + # placed after `load_config()` so a startup whose configuration is broken + # fails without touching the host's register at all, and it sits in no path + # derivation — asking where the register lives must never move it. + migrate_legacy_registry() result = asyncio.run(run_server(config.llm)) except KeyboardInterrupt: # SIGINT delivered at the event-loop poll point escapes the main diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index d6021f66..dc45b2e1 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -39,6 +39,7 @@ from emrg.config import ( LlmConfig, SandboxConfig, + agents_file, config_dir, logs_dir, find_model_entry, @@ -868,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 = runtime_dir / "projects.yml" + self._projects_log = agents_file(runtime_dir) self._rants_log = runtime_dir / "rants.jsonl" # ── Phase 2 broadcast model (protocol-contract §2.6) ── @@ -1248,7 +1249,7 @@ def _rebuild_sessions_index(self) -> None: """Backfill the global cross-project session index at startup. Rant 2026-08-13T16:42:22: sessions created before this feature (or in - projects not registered in projects.yml) would otherwise be invisible + projects not registered in agents.yml) would otherwise be invisible to other projects. Best-effort — failures are logged at debug level and never crash the daemon. """ @@ -2103,7 +2104,7 @@ async def _broadcast_all(self, data: dict, exclude=None) -> None: # ── Project tracking ───────────────────────────────────── def _touch_project(self, cwd: str) -> None: - """Record a project as active in ~/.emrg/projects.yml. + """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. @@ -3456,7 +3457,7 @@ async def _process_message( elif msg_type == "remove_project": # P1 GUI multi-session (rant 2026-08-10T15:07:19): drop a - # projects.yml entry by name (disk session data preserved). + # agents.yml entry by name (disk session data preserved). name = msg.get("name", "").strip() if not name: await self._send(ws, { @@ -6684,14 +6685,14 @@ async def _handle_list_sessions( async def _handle_list_projects( self, ws ) -> None: - """Read projects.yml and return all project entries. + """Read agents.yml and return all project entries. 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. - No evolution-workspace filter (rant 2026-08-07T10:48:00): projects.yml + 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. @@ -6738,17 +6739,17 @@ async def _latest_session_at(entry: dict) -> str: for (p, at), repo in zip(ordered, repos) ] except (yaml.YAMLError, OSError): - logger.exception("Failed to read projects.yml") + logger.exception("Failed to read agents.yml") await self._send(ws, { "type": "projects_list", "projects": projects, }) async def _handle_remove_project(self, name: str, ws) -> None: - """Remove a project entry from projects.yml by name. + """Remove a project entry from agents.yml by name. P1 of the GUI multi-session feature (rant 2026-08-10T15:07:19): - deletes only the projects.yml entry — on-disk session data under + 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: @@ -6774,7 +6775,7 @@ async def _handle_remove_project(self, name: str, ws) -> None: "type": "project_removed", "removed": False, "name": name, - "error": "failed to read projects.yml", + "error": "failed to read agents.yml", }) return if not isinstance(data, list): @@ -6803,10 +6804,10 @@ async def _handle_remove_project(self, name: str, ws) -> None: "type": "project_removed", "removed": False, "name": name, - "error": "failed to write projects.yml", + "error": "failed to write agents.yml", }) return - logger.info("remove_project: removed %s from projects.yml", name) + logger.info("remove_project: removed %s from agents.yml", name) await self._send(ws, { "type": "project_removed", "removed": True, diff --git a/emrg/server/evolution_prompt.md b/emrg/server/evolution_prompt.md index 3408fd10..d4f666d0 100644 --- a/emrg/server/evolution_prompt.md +++ b/emrg/server/evolution_prompt.md @@ -127,7 +127,7 @@ Read what others landed, why, and whether it needs follow-up. Use `FETCH_HEAD`, **Coverage is not negotiable; depth is.** Every cycle asks about **every** project whether anything changed — step 0 reads no content, and it is the step that is never skipped. How far into a project the cycle then goes is decided by evidence. Nothing here shrinks §1: every step of §1–§4 is still executed, and R10's rule — a cycle saves rounds by asking for several things at once, never by asking for less — is not relaxed by a word of it. ```bash -cat ~/.emrg/projects.yml +cat ~/.emrg/agents.yml # step 0, per project: what under its memory is newer than its bookmark time? find /.emrg -newermt '' \ \( -path '*/memory/*.md' -o -name 'MEMORY.md' \) 2>/dev/null | head diff --git a/emrg/server/promote_prompt.md b/emrg/server/promote_prompt.md index 42626b05..6abe0528 100644 --- a/emrg/server/promote_prompt.md +++ b/emrg/server/promote_prompt.md @@ -32,7 +32,7 @@ Run this loop every round; never stop at "executed": --- ### Current State -- Promotion target: {{ project.name }}({% if project.description %}{{ project.description }}{% else %}see projects.yml for description{% endif %}) +- Promotion target: {{ project.name }}({% if project.description %}{{ project.description }}{% else %}see agents.yml for description{% endif %}) - Project path: `{{ project.path }}` - Session ID: `{{ session_id }}` @@ -63,7 +63,7 @@ Durable facts — `channel accounts`, `blog posts`, `blog drafts`, `banned list` #### 0.2 Read the project config -Read the full config of `{{ project.name }}` from `~/.emrg/projects.yml` (path, name, description, custom fields) as context. The `project` variable in the template (name/path/description and other fields) is already injected into this prompt. +Read the full config of `{{ project.name }}` from `~/.emrg/agents.yml` (path, name, description, custom fields) as context. The `project` variable in the template (name/path/description and other fields) is already injected into this prompt. #### 0.3 Confirm promotion channel availability @@ -307,7 +307,7 @@ here, because a second copy of a rule is a copy that can disagree. So there is nothing for this prompt to write by hand — and no reason to open the file at all, not even to read it. -- `project` = the promoted project's registered short name (as in `~/.emrg/projects.yml`); the rant is then handled by that project's evolution task. The tool warns when the name is not registered. +- `project` = the promoted project's registered short name (as in `~/.emrg/agents.yml`); the rant is then handled by that project's evolution task. The tool warns when the name is not registered. - Deduplicate before submitting: `submit_rant(action="list", project="{{ project.name }}")` and skip feedback that is already queued. - Each message notes the source (channel + link) so the evolution task can trace back. diff --git a/emrg/server/scheduler.py b/emrg/server/scheduler.py index 33a18688..21884acd 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). -projects.yml remains for project tracking (_touch_project only). +agents.yml remains for project tracking (_touch_project only). Task config schema: name, type, enabled, interval — common base fields. @@ -15,7 +15,7 @@ clock and whatever its zone does with DST. Weekdays only: no holidays, no make-up workdays. config — type-specific config, and the mapping the templates see as ``task``. For - evolution: config.project links to projects.yml name. + evolution: config.project links to agents.yml name. sandbox — optional; also accepted under config, where the record-level value wins. extra_prompt — optional; also accepted at the record's top level, where config wins (the templates read it as ``task.extra_prompt``; see ``_template_task_context``). @@ -36,7 +36,7 @@ from pathlib import Path import yaml -from emrg.config import config_dir +from emrg.config import agents_file, config_dir from emrg.connect import connect_to_server from emrg.memory import NotARegularFile from emrg.tools.base import non_regular_kind @@ -195,7 +195,7 @@ def recovery_recipe(stash_message: str) -> str: # 超长——那些会逃出目录或造出非法文件名。大小写不在其中。 # # 原先只收小写,于是一条**已经在 tasks.yml 里**的记录被加载器每个 tick 拒绝: -# `OfficeCLI-opensource-task`(宿主照 projects.yml 里真实的项目名 `OfficeCLI` 命名)。 +# `OfficeCLI-opensource-task`(宿主照 agents.yml 里真实的项目名 `OfficeCLI` 命名)。 # 实测 2026-09-30:`~/.emrg/emrgd.log` 里同一条错误 3963 行,06:51:45 首发、11:19:11 # 仍在发,任务整上午一次没跑——而记录里仍写着 `enabled: true`,因为任务列表就是从 # 这个文件建的,一个被拒的任务看起来和正在跑的一模一样。同一处校验还挡着 CRUD 的 @@ -933,8 +933,8 @@ def _resolve_task_template(task_type: str) -> Path: def _resolve_project_path(name: str) -> str | None: - """Resolve a project name to its path from projects.yml.""" - projects_file = config_dir() / "projects.yml" + """Resolve a project name to its path from agents.yml.""" + projects_file = agents_file(config_dir()) if not projects_file.exists(): return None try: @@ -950,14 +950,14 @@ def _resolve_project_path(name: str) -> str | None: def _load_project_config(name: str, source_dir: str) -> dict: - """Return the full projects.yml entry matching the current project. + """Return the full agents.yml entry matching the current project. Matches by ``name`` first, then by ``path`` against ``source_dir``. Returns an empty dict when no match is found. Custom fields added by - the user in projects.yml are preserved, so templates can reference + the user in agents.yml are preserved, so templates can reference them via ``{{ project. }}`` without code changes. """ - projects_file = config_dir() / "projects.yml" + projects_file = agents_file(config_dir()) if not projects_file.exists(): return {} try: @@ -3313,7 +3313,7 @@ def _build_evolution_prompt(self) -> str: context renders as an empty string instead of raising (the old ``str.format()`` crashed with KeyError on missing placeholders). Templates also receive ``task`` (the tasks.yml config dict) and - ``project`` (the matching projects.yml entry) so users can reference + ``project`` (the matching agents.yml entry) so users can reference custom fields via ``{{ task.x }}`` / ``{{ project.x }}`` without code changes. """ @@ -3632,21 +3632,21 @@ def _write_table(self, records: list[dict]) -> None: write_table(records, self._tasks_file) def _ensure_emrg_project_entry(self) -> None: - """Ensure projects.yml has an emrg entry. + """Ensure agents.yml has an emrg entry. Not a task seeder any more (design decision D2, rant 2026-09-28T09:54:10): the default table is seeded by `read_table` when *the file* is missing, and a record deleted from a file that exists is terminal — re-adding it here would resurrect exactly what the host deleted. - What is still needed is the project entry (rant 02:58): if projects.yml lacks + What is still needed is the project entry (rant 02:58): if agents.yml lacks `emrg`, `_resolve_project_path("emrg")` returns None and the handler's `_source_dir` degenerates to the relative string "emrg" (a dangling cwd). The path is fixed to ~/.emrg/evolution/emrg; an existing entry is preserved as-is (dev machines may configure a custom path). """ - # projects.yml — add name=emrg entry if missing (preserve existing). - projects_file = config_dir() / "projects.yml" + # agents.yml — add name=emrg entry if missing (preserve existing). + projects_file = agents_file(config_dir()) try: entries: list[dict] = [] if projects_file.exists(): @@ -3661,12 +3661,12 @@ def _ensure_emrg_project_entry(self) -> None: }) atomic_write_yaml(entries, projects_file, prefix=".projects_") logger.info( - "TaskScheduler: self-heal — added emrg entry to projects.yml" + "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 - # projects.yml by a test run; the dir is deleted after the + # 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 @@ -3687,7 +3687,7 @@ def _ensure_emrg_project_entry(self) -> None: break except (yaml.YAMLError, OSError) as e: logger.warning( - "TaskScheduler: projects.yml self-heal failed: %s", e + "TaskScheduler: agents.yml self-heal failed: %s", e ) # ── Task CRUD + hot reload (rant 2026-08-12T18:23:15 P2) ──────── @@ -3703,7 +3703,7 @@ def _validate_task_fields( if task_type not in TASK_TEMPLATES and task_type not in _custom_templates(): return f"unknown task type {task_type!r} (not builtin, no custom template)" if not project or _resolve_project_path(project) is None: - return f"project {project!r} is not registered in projects.yml" + return f"project {project!r} is not registered in agents.yml" if not isinstance(interval, int) or isinstance(interval, bool) or interval < MIN_INTERVAL: return f"interval must be an integer >= {MIN_INTERVAL}" # The same validator the loader runs: one shape, one set of messages. diff --git a/emrg/sessions_index.py b/emrg/sessions_index.py index 4c6a998f..800643d6 100644 --- a/emrg/sessions_index.py +++ b/emrg/sessions_index.py @@ -205,7 +205,7 @@ def _iter_nested_sessions(root: Path): Recursively walks ``root`` (pruning heavy subtrees) so unregistered projects under ~/.emrg (e.g. ~/.emrg itself, ~/.emrg/source) are covered, - not just paths listed in projects.yml. + not just paths listed in agents.yml. """ for dirpath, dirnames, _ in os.walk(root, followlinks=False): dirnames[:] = [d for d in dirnames if d not in _PRUNE_DIRS] diff --git a/emrg/tools/file_policy.py b/emrg/tools/file_policy.py index c467f5e0..ea677f16 100644 --- a/emrg/tools/file_policy.py +++ b/emrg/tools/file_policy.py @@ -46,20 +46,26 @@ WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:[\\/]") #: Daemon state files a sandboxed tool call must never write, at any tier. Each is -#: a file the host owns and the daemon reads at startup. +#: a file the host owns and the daemon reads at startup. The agent registry is +#: not spelled here: its path is derived (:func:`emrg.config.agents_file`), so it +#: joins this list where names become paths. PROTECTED_FILES = ( "~/.emrg/config.toml", "~/.emrg/emrgd.token", "~/.emrg/tasks.yml", - "~/.emrg/projects.yml", "~/.emrg/rants.jsonl", ) def protected_paths() -> list[str]: """Canonicalized (realpath) protected daemon state files.""" + # Imported at call time, not module scope: `emrg.config` imports + # `emrg.tools.base`, and this module is reached from the tool packages that + # sit beside it (#2521). + from emrg.config import agents_file + out: list[str] = [] - for p in PROTECTED_FILES: + for p in (*PROTECTED_FILES, str(agents_file())): try: out.append(os.path.realpath(os.path.expanduser(p))) except OSError: diff --git a/emrg/tools/submit_rant_tool.py b/emrg/tools/submit_rant_tool.py index 38cd4f27..588136fb 100644 --- a/emrg/tools/submit_rant_tool.py +++ b/emrg/tools/submit_rant_tool.py @@ -162,7 +162,7 @@ def definition(self) -> ToolDefinition: "type": "string", "description": ( "REQUIRED for submit — target project name = the " - "short `name` registered in ~/.emrg/projects.yml " + "short `name` registered in ~/.emrg/agents.yml " "(e.g. 'emrg', 'aitokenpool') — NOT the GitHub " "owner/repo form (rant 2026-08-24T10:54:04). If " "you cannot determine which project the rant " @@ -296,7 +296,7 @@ def _execute_submit(self, arguments: dict) -> ToolResult: ) target = f" ({project})" if project else "" # Rant 2026-08-24T10:54:04: project must be the short name registered - # in ~/.emrg/projects.yml — not the GitHub owner/repo form. When the + # in ~/.emrg/agents.yml — not the GitHub owner/repo form. When the # given name is not registered, append a non-blocking warning with the # registered candidates so a copy-pasted wrong name is caught at # submit time instead of requiring a manual re-submit + void. @@ -305,7 +305,7 @@ def _execute_submit(self, arguments: dict) -> ToolResult: candidates = " | ".join(sorted(registered)) warning = ( f"\n⚠ project {project!r} is not a registered name in " - f"~/.emrg/projects.yml (registered: {candidates}) — use the " + f"~/.emrg/agents.yml (registered: {candidates}) — use the " f"short `name` (e.g. 'emrg' or 'aitokenpool')." ) else: @@ -316,7 +316,7 @@ def _execute_submit(self, arguments: dict) -> ToolResult: ) def _registered_project_names(self) -> list[str]: - """Short names registered in ~/.emrg/projects.yml (config dir). + """Short names registered in ~/.emrg/agents.yml (config dir). The daemon registers projects there on first use; the list is the authoritative source for the rant ``project`` field. Any read error @@ -331,7 +331,9 @@ def _registered_project_names(self) -> list[str]: try: import yaml - p = self._rants_log().parent / "projects.yml" + from emrg.config import agents_file + + p = agents_file(self._rants_log().parent) try: kind = non_regular_kind(p) except OSError: diff --git a/scripts/check-memory-rotation.py b/scripts/check-memory-rotation.py index f4b7dbd3..7a1170a2 100644 --- a/scripts/check-memory-rotation.py +++ b/scripts/check-memory-rotation.py @@ -17,10 +17,10 @@ bookmark line, and what is the newest file**. Step 0 is the step that is never skipped, and nothing in this repository performed it - `check-memory-index.py` measures an index against the index rule's two numbers and never looks at -`projects.yml`. Every cycle therefore hand-wrote the probe in a `bash` heredoc, +`agents.yml`. Every cycle therefore hand-wrote the probe in a `bash` heredoc, and it answered wrongly in two ways that both read as a clean result. -**Keying.** A probe that keys rows by `projects.yml`'s `name` field cannot +**Keying.** A probe that keys rows by `agents.yml`'s `name` field cannot resolve every row the bookmark carries. The bookmark writes a row's key as whatever disambiguates that project, and this host's bookmark has three forms: `emrg` (a bare name), `aitokenpool (github)` (a name plus the parenthetical that @@ -41,7 +41,7 @@ What it reads, and what it prints first -------------------------------------- -Two subjects, both named before any verdict: the `projects.yml` (the register of +Two subjects, both named before any verdict: the `agents.yml` (the register of projects this installation tracks) and the **bookmark** (one Markdown-table row per project: the newest memory modification time already read, and the cycle that read it). Per project it looks under `.emrg/memory/**/*.md` and under @@ -68,7 +68,7 @@ contradicts (`row says path missing`, `row says no memory root`) is a fault with the row named. Repeating a note would hide a project that came back, or one that started writing memory, for ever - the same stale-past-state defect as comparing a whole second against a sub-second mtime. - no bookmark row the project has a line in `projects.yml` and none in the + no bookmark row the project has a line in `agents.yml` and none in the bookmark: it has never been visited, and §1.5 reads it Exit codes @@ -77,10 +77,23 @@ 1 the coverage the bookmark exists to guarantee is broken: a project with no row, a row naming no project, a row that matches more than one, or a row whose state field is none of the three forms above - 2 nothing was measured: no `projects.yml`, no bookmark, or a stamp that cannot be + 2 nothing was measured: no `agents.yml`, no bookmark, or a stamp that cannot be ordered against an mtime - a value that does not parse, or one later than the instant of the reading - which is never a pass +**The register's name, and one window where two names exist.** The register is +`agents.yml`. It was called `projects.yml` until an Agent became the record of the +directory a session runs in, and the mopping-up move belongs to the daemon that owns +the file (`emrg.config.migrate_legacy_registry`), which performs it when it starts. +This tree can therefore run ahead of that daemon: the rename travels in a release, +and installing a release is what restarts the daemon that does the move. In that +window the no-argument form - the one every cycle is told to run - would answer +`not measurable` on every run and stop the rotation's clock. So the default falls +back: when `agents.yml` is absent and `projects.yml` is present, the reading is taken +on `projects.yml`, a note on stderr says so, and the first line of the report names +that file as the subject. Once the daemon has moved the register the fallback is +never reached, and a run that names `--projects` explicitly is never second-guessed. + What it does not do ------------------- It **measures and never repairs**: it does not rewrite the bookmark, add a row, or @@ -166,9 +179,25 @@ def now() -> datetime: #: Both defaults are this host's paths. They are defaults rather than required #: arguments because step 0 runs every cycle on this installation; any other #: layout is reached by naming the two paths, and the report says which it read. -DEFAULT_PROJECTS = "~/.emrg/projects.yml" +DEFAULT_PROJECTS = "~/.emrg/agents.yml" DEFAULT_BOOKMARK = "~/.emrg/evolution/emrg/.emrg/memory/project-memory-rotation-bookmark.md" +#: The register's name before an Agent became the record of the directory a +#: session runs in. **Named here because this script runs without the package** — +#: it is stdlib-only, so it cannot ask `emrg.config`, and `emrg.config` is where +#: the move itself lives. +#: +#: The fallback below exists for one window, and the window is not hypothetical: +#: a checkout can run ahead of the daemon that holds the register. The code that +#: moves the file ships in a release, and what restarts the daemon that performs +#: the move is installing one — so between the change landing and the next +#: release, this tree names `agents.yml` while the host still keeps its register +#: at the old name. A default naming only the new file would answer +#: `not measurable` on every cycle of that window and stop the rotation's clock; +#: the fallback reads what is there and **says which file it read**, so the +#: reading never claims a subject it did not open. +LEGACY_PROJECTS = "~/.emrg/projects.yml" + class Unmeasurable(Exception): """A reading could not be taken. Reported as exit 2, never as a pass.""" @@ -689,6 +718,19 @@ def main(argv: list[str] | None = None) -> int: projects_path = Path(os.path.expanduser(args.projects)) bookmark_path = Path(os.path.expanduser(args.bookmark)) + # The one window where two names exist, and never a second guess at an + # explicit argument: only the default is allowed to fall back, so a caller + # that named a register is answered about that register. + if args.projects == DEFAULT_PROJECTS and not projects_path.exists(): + legacy_path = Path(os.path.expanduser(LEGACY_PROJECTS)) + if legacy_path.exists(): + print( + f"note: {projects_path} does not exist yet - reading {legacy_path}, " + f"the register's pre-rename name (it moves when the daemon that owns it " + f"next starts)", + file=sys.stderr, + ) + projects_path = legacy_path try: reading = read(projects_path, bookmark_path) except Unmeasurable as exc: diff --git a/tests/conftest.py b/tests/conftest.py index d90f6831..c622caa2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,7 @@ """Shared pytest fixtures — hermeticity guards. 2026-08-13 incident: the full test suite intermittently wrote pytest -temp paths into the real ~/.emrg/projects.yml. The mechanism is a +temp paths into the real ~/.emrg/agents.yml. The mechanism is a feedback loop — once a stale entry lands in the file (seeded by the workspace self-heal family), subsequent tests re-read it and re-write it, advancing the pytest-N counter on every full run. The daemon's @@ -11,13 +11,13 @@ CI finding (PR #738): test_ws_e2e._boot_server only patched daemon/connect config_dir, but EmrgServer.serve() builds a real TaskScheduler whose load_and_start() → _ensure_emrg_project_entry() -writes config_dir()/projects.yml, and whose reader/writer (`read_table`, +writes config_dir()/agents.yml, and whose reader/writer (`read_table`, `write_table`) touch tasks.yml — all through scheduler.py's own (unpatched) config_dir → on a fresh runner this hits the real ~/.emrg/ files. This autouse fixture makes any write to the REAL ~/.emrg/config.toml, -~/.emrg/projects.yml or ~/.emrg/tasks.yml a hard test failure: the offending +~/.emrg/agents.yml or ~/.emrg/tasks.yml a hard test failure: the offending test is named immediately instead of the pollution being discovered later (precedent: #583 assertPortFileInTmp sandbox guard for the emrgd.token file). """ @@ -32,7 +32,7 @@ _REAL_CONFIG_FILES = ( (Path.home() / ".emrg" / "config.toml").resolve(), - (Path.home() / ".emrg" / "projects.yml").resolve(), + (Path.home() / ".emrg" / "agents.yml").resolve(), (Path.home() / ".emrg" / "tasks.yml").resolve(), ) @@ -81,7 +81,7 @@ def _guard_real_config_files(monkeypatch): rather than in any channel a startup-failure report reads. **The second seam is the plain one.** ``atomic_write_yaml`` is how - ``projects.yml`` and ``tasks.yml`` are saved, and patching it was enough + ``agents.yml`` and ``tasks.yml`` are saved, and patching it was enough while those were the only two files here. ``config.toml`` is not YAML: it is written by ``ensure_config`` through ``cfg_path.write_text``, which that patch never sees. So a guard naming only the YAML seam would leave @@ -164,7 +164,7 @@ def _redirect_sessions_index(monkeypatch, tmp_path): Session._save_meta_with_title / Session.delete write to on every session create/append/delete. Without redirection, the whole session test suite would pollute the host's real index with pytest temp paths (same class as - the projects.yml leak guarded above). + the agents.yml leak guarded above). """ import emrg.sessions_index as sidx diff --git a/tests/test_a_tree_reading_guard_names_its_tree.py b/tests/test_a_tree_reading_guard_names_its_tree.py index 35222ee9..a6870035 100644 --- a/tests/test_a_tree_reading_guard_names_its_tree.py +++ b/tests/test_a_tree_reading_guard_names_its_tree.py @@ -314,7 +314,7 @@ def _refusal_reason(out: str) -> str: "check-patch-files.py": "reads the patch files it is given as arguments", "check-memory-rotation.py": ( "reads two host-state subjects it names before any verdict - the project register " - "(`~/.emrg/projects.yml`) and the rotation bookmark (a Markdown table of the newest " + "(`~/.emrg/agents.yml`) and the rotation bookmark (a Markdown table of the newest " "memory modification time already read per project) - then walks each project's " "`.emrg/memory/` and `.emrg/sessions/*/memory/`, which are gitignored host data no " "checkout carries. A working tree is not its subject and no tree's name would answer " diff --git a/tests/test_agent_md_prompt_cap.py b/tests/test_agent_md_prompt_cap.py index b8b415e8..825232ed 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()) / "projects.yml" + server._projects_log = Path(tempfile.mkdtemp()) / "agents.yml" return server diff --git a/tests/test_agents_registry_migration.py b/tests/test_agents_registry_migration.py new file mode 100644 index 00000000..344ec248 --- /dev/null +++ b/tests/test_agents_registry_migration.py @@ -0,0 +1,190 @@ +"""The agent register's rename: `projects.yml` → `agents.yml` (#2521). + +Until an Agent became the record of the directory a session runs in, the +installation's register of those directories was `~/.emrg/projects.yml`. The +content does not change with the name — the same entries, the same fields, the +same `last_active` — so what these tests hold is the *transition*: one move, +whole, once, and never from a reader that only asked where the file lives. +""" + +from __future__ import annotations + +import types +from pathlib import Path + +import pytest +import yaml + +from emrg.config import agents_file, migrate_legacy_registry + +#: A register with what the daemon cares about: a `name`/`path` pair, and the +#: `last_active` stamp the pickers sort by. The second entry carries a custom +#: field, because entries are the host's to extend. +REGISTER = [ + {"name": "emrg", "path": "/home/h/evolution/emrg", "last_active": "2026-10-23T13:00:00"}, + {"name": "aitokenpool", "path": "/home/h/aitokenpool", "last_active": "2026-10-22T09:12:04", + "notes": "host-added field"}, +] + + +def _legacy(directory: Path) -> Path: + """Write the pre-rename register. The one place a test spells the old name.""" + directory.mkdir(parents=True, exist_ok=True) + path = directory / "projects.yml" + path.write_text(yaml.safe_dump(REGISTER, allow_unicode=True), encoding="utf-8") + return path + + +def test_the_move_carries_every_entry_and_its_last_active(tmp_path): + legacy = _legacy(tmp_path) + + assert migrate_legacy_registry(tmp_path) is True + + moved = tmp_path / "agents.yml" + assert moved.exists(), "the register did not arrive at its new name" + assert not legacy.exists(), "the move copied instead of moving — two registers now" + assert yaml.safe_load(moved.read_text(encoding="utf-8")) == REGISTER + + +def test_a_register_already_at_the_new_name_is_never_moved(tmp_path): + current = tmp_path / "agents.yml" + current.write_text(yaml.safe_dump([{"name": "mine", "path": "/one"}]), encoding="utf-8") + # An older daemon re-created the legacy name; the new name governs and must + # not be overwritten by it. + _legacy(tmp_path) + + assert migrate_legacy_registry(tmp_path) is False + + assert yaml.safe_load(current.read_text(encoding="utf-8")) == [ + {"name": "mine", "path": "/one"} + ] + + +def test_a_register_that_never_existed_is_not_created(tmp_path): + assert migrate_legacy_registry(tmp_path) is False + assert not (tmp_path / "agents.yml").exists(), ( + "a read of an installation with no register must not materialise an empty one" + ) + + +def test_a_move_that_cannot_happen_leaves_the_legacy_register_readable(tmp_path, monkeypatch): + legacy = _legacy(tmp_path) + + def _refuse(self, target): + raise OSError(30, "Read-only file system") + + monkeypatch.setattr(Path, "rename", _refuse) + + assert migrate_legacy_registry(tmp_path) is False, "a failed move is not a move" + + assert legacy.exists(), "the register was lost on a failed move" + assert yaml.safe_load(legacy.read_text(encoding="utf-8")) == REGISTER + + +def test_a_second_caller_finds_the_work_done(tmp_path): + _legacy(tmp_path) + + assert migrate_legacy_registry(tmp_path) is True + assert migrate_legacy_registry(tmp_path) is False + + assert yaml.safe_load((tmp_path / "agents.yml").read_text(encoding="utf-8")) == REGISTER + + +def test_asking_for_the_path_never_moves_the_register(tmp_path): + """The producer answers a path and opens nothing. + + This is the separation that keeps a test run from renaming the host's + register under a daemon that is still reading the old name: the suite + constructs every reader of the register, so a move hidden in the path + derivation would run during a suite. + """ + legacy = _legacy(tmp_path) + + assert agents_file(tmp_path) == tmp_path / "agents.yml" + + assert legacy.exists(), "asking where the register lives moved it" + assert not (tmp_path / "agents.yml").exists() + + +def test_the_daemon_entrypoint_migrates_before_it_serves(monkeypatch): + """The one process that owns the register moves it, before the server exists. + + `main()` is called with every side effect stubbed — the shape + `test_daemon_start_diagnostics` uses — so nothing starts, stops or restarts a + daemon and no host file is opened. What is asserted is the order: the move + runs, and it runs before the server object that asks for the path. + """ + from emrg.server import __main__ as server_main + + calls: list[str] = [] + + monkeypatch.setattr(server_main, "ensure_tool_dirs", lambda: []) + monkeypatch.setattr(server_main, "_configure_logging", lambda: None) + monkeypatch.setattr(server_main, "_redirect_std_streams", lambda: None) + monkeypatch.setattr( + server_main, + "migrate_legacy_registry", + lambda *a, **k: calls.append("migrate") or True, + ) + monkeypatch.setattr( + "emrg.config.load_config", + lambda: types.SimpleNamespace(llm=None), + ) + + class _Exit: + exit_code = 0 + + def write_record(self) -> None: + pass + + async def _run_server(llm): + calls.append("serve") + return _Exit() + + monkeypatch.setattr(server_main, "run_server", _run_server) + + with pytest.raises(SystemExit): + server_main.main() + + assert calls == ["migrate", "serve"], calls + + +def test_the_old_name_is_spelled_only_where_the_move_is(): + """One producer: the register's name lives in one module. + + A reading of the source, not of behaviour, because the question is exactly + "how many places spell this name" — and a second spelling is what a rename + leaves behind. `emrg/config.py` is the move itself; every other file asks. + + One carrier is exempt, and it is enumerated rather than matched: the rotation + script runs stdlib-only, so it cannot ask `emrg.config`, and it must keep + answering while a checkout is ahead of the daemon that performs the move + (`agents.yml` travels in a release, and installing one is what restarts that + daemon). Its exemption is checked to be live below — an exemption that no + longer names anything fails, so this list cannot rot into a permission. + """ + root = Path(__file__).resolve().parent.parent + exemptions = ("scripts/check-memory-rotation.py",) + for name in exemptions: + text = (root / name).read_text(encoding="utf-8", errors="replace") + assert "projects.yml" in text, ( + f"{name} is exempted from the one-name rule but no longer names the old " + f"name — drop it from `exemptions` rather than leaving a permission" + ) + offenders: list[str] = [] + for area in ("emrg", "scripts"): + for path in (root / area).rglob("*.py"): + relative = path.relative_to(root).as_posix() + if path == root / "emrg" / "config.py" or relative in exemptions: + continue + text = path.read_text(encoding="utf-8", errors="replace") + if "projects.yml" in text: + offenders.append(f"{relative}: old name") + if "agents.yml" in text and "config_dir()" in text: + # A path built from the config directory again, rather than asked for. + for line in text.splitlines(): + if "agents.yml" in line and "config_dir()" in line: + offenders.append(f"{relative}: {line.strip()}") + assert not offenders, "the register's path is built outside its one producer:\n" + "\n".join( + offenders + ) diff --git a/tests/test_atomic.py b/tests/test_atomic.py index 902bf625..a0232c90 100644 --- a/tests/test_atomic.py +++ b/tests/test_atomic.py @@ -61,13 +61,13 @@ def test_atomic_write_empty_list(tmp_path: Path): def test_atomic_write_no_temp_leak(tmp_path: Path): """Verifies no temp files remain after write.""" before = set(os.listdir(str(tmp_path))) - target = tmp_path / "projects.yml" + target = tmp_path / "agents.yml" atomic_write_yaml([{"name": "test"}], target) after = set(os.listdir(str(tmp_path))) # Only the target file should exist, no temp leftovers - assert "projects.yml" in after - assert after - before == {"projects.yml"} + assert "agents.yml" in after + assert after - before == {"agents.yml"} def test_atomic_write_bytes_basic(tmp_path: Path): diff --git a/tests/test_boss_tool.py b/tests/test_boss_tool.py index 6fd7923f..fb2f7dc5 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 / "projects.yml" + server._projects_log = tmp_path / "agents.yml" server._maybe_reflect_memory = lambda *a, **k: None return server diff --git a/tests/test_check_memory_rotation.py b/tests/test_check_memory_rotation.py index 5bcdc09f..7c74a6fd 100644 --- a/tests/test_check_memory_rotation.py +++ b/tests/test_check_memory_rotation.py @@ -97,7 +97,7 @@ def _bookmark(path: Path, rows: list[tuple[str, str]]) -> Path: def _reading(mod, tmp_path: Path, projects: list[dict], rows: list[tuple[str, str]]): - register = _projects(tmp_path / "projects.yml", projects) + register = _projects(tmp_path / "agents.yml", projects) book = _bookmark(tmp_path / "bookmark.md", rows) return mod.read(register, book) @@ -173,7 +173,7 @@ def test_the_bookmark_does_not_count_as_memory_of_the_project_that_holds_it(mod, """ root = Path(_root(tmp_path, "holder")) _write_memory(root, ".emrg/memory/MEMORY.md", _stamp(mod)) - register = _projects(tmp_path / "projects.yml", [{"name": "holder", "path": str(root)}]) + register = _projects(tmp_path / "agents.yml", [{"name": "holder", "path": str(root)}]) book = _bookmark(root / ".emrg/memory/bookmark.md", [("`holder`", BASE)]) os.utime(book, (_stamp(mod, 60), _stamp(mod, 60))) reading = mod.read(register, book) @@ -189,7 +189,7 @@ def test_a_memory_file_newer_than_the_row_still_reads_changed_beside_the_bookmar """ root = Path(_root(tmp_path, "holder-with-news")) newer = _write_memory(root, ".emrg/memory/MEMORY.md", _stamp(mod, 60)) - register = _projects(tmp_path / "projects.yml", + register = _projects(tmp_path / "agents.yml", [{"name": "holder-with-news", "path": str(root)}]) book = _bookmark(root / ".emrg/memory/bookmark.md", [("`holder-with-news`", BASE)]) os.utime(book, (_stamp(mod, 30), _stamp(mod, 30))) @@ -218,7 +218,7 @@ def test_a_row_stamped_later_than_the_clock_is_unmeasurable(mod, tmp_path, monke taken = datetime.strptime(BASE, mod.STAMP_FORMAT) monkeypatch.setattr(mod, "now", lambda: taken) ahead = (taken + timedelta(seconds=1)).strftime(mod.STAMP_FORMAT) - register = _projects(tmp_path / "projects.yml", [{"name": "ahead", "path": root}]) + register = _projects(tmp_path / "agents.yml", [{"name": "ahead", "path": root}]) book = _bookmark(tmp_path / "ahead.md", [("`ahead`", ahead)]) with pytest.raises(mod.Unmeasurable) as excinfo: mod.read(register, book) @@ -241,7 +241,7 @@ def test_a_row_stamped_at_the_clock_is_read_normally(mod, tmp_path, monkeypatch) _write_memory(Path(root), ".emrg/memory/MEMORY.md", _stamp(mod)) taken = datetime.strptime(BASE, mod.STAMP_FORMAT) monkeypatch.setattr(mod, "now", lambda: taken) - register = _projects(tmp_path / "projects.yml", [{"name": "at-the-clock", "path": root}]) + register = _projects(tmp_path / "agents.yml", [{"name": "at-the-clock", "path": root}]) book = _bookmark(tmp_path / "at.md", [("`at-the-clock`", BASE)]) reading = mod.read(register, book) assert reading["states"][0]["state"] == "unchanged" @@ -350,7 +350,7 @@ def test_a_project_whose_name_looks_numeric_still_matches_its_row(mod, tmp_path) """ root = _root(tmp_path, "numeric") _write_memory(Path(root), ".emrg/memory/MEMORY.md", _stamp(mod)) - register = tmp_path / "projects.yml" + register = tmp_path / "agents.yml" register.write_text(f"- name: 1\n path: '{root}'\n", encoding="utf-8") book = _bookmark(tmp_path / "bookmark.md", [("`1`", BASE)]) reading = mod.read(register, book) @@ -387,7 +387,7 @@ def test_a_row_is_found_in_a_table_of_any_width(mod, tmp_path): narrow = _root(tmp_path, "narrow") for root in (wide, narrow): _write_memory(Path(root), ".emrg/memory/MEMORY.md", _stamp(mod, 60)) - register = _projects(tmp_path / "projects.yml", [ + register = _projects(tmp_path / "agents.yml", [ {"name": "wide", "path": wide}, {"name": "narrow", "path": narrow}]) # The same two rows, in the two tables the two installations write. The wide one puts @@ -422,7 +422,7 @@ def test_a_row_that_carries_no_stamp_is_unmeasurable_rather_than_dropped(mod, tm original defect - so it is exit 2, and the message names the line it could not read. """ root = _root(tmp_path, "unreadable") - register = _projects(tmp_path / "projects.yml", [{"name": "unreadable", "path": root}]) + register = _projects(tmp_path / "agents.yml", [{"name": "unreadable", "path": root}]) book = _bookmark(tmp_path / "bookmark.md", [("`unreadable`", "read on Tuesday")]) with pytest.raises(mod.Unmeasurable) as excinfo: mod.read(register, book) @@ -441,7 +441,7 @@ def test_a_header_written_with_a_code_span_is_not_a_body_row(mod, tmp_path): """ root = _root(tmp_path, "spanned") _write_memory(Path(root), ".emrg/memory/MEMORY.md", _stamp(mod, 60)) - register = _projects(tmp_path / "projects.yml", [{"name": "spanned", "path": root}]) + register = _projects(tmp_path / "agents.yml", [{"name": "spanned", "path": root}]) book = tmp_path / "bookmark.md" book.write_text("\n".join([ "# Bookmark", "", @@ -466,7 +466,7 @@ def test_a_bookmark_in_another_shape_is_unmeasurable_rather_than_empty(mod, tmp_ bookmark and a prose-only bookmark both answered `0 row(s)` with a fault per project. """ root = _root(tmp_path, "unlisted") - register = _projects(tmp_path / "projects.yml", [{"name": "unlisted", "path": root}]) + register = _projects(tmp_path / "agents.yml", [{"name": "unlisted", "path": root}]) for shape, text in ( ("one line per project", f"# Rotation bookmark\n\n- `unlisted` {BASE} (cyc20261020-214924)\n"), @@ -489,7 +489,7 @@ def test_a_body_row_that_names_its_project_in_no_code_span_is_unmeasurable(mod, refusals are the same defect - a shape the reader does not know answered as an absence. """ root = _root(tmp_path, "plain") - register = _projects(tmp_path / "projects.yml", [{"name": "plain", "path": root}]) + register = _projects(tmp_path / "agents.yml", [{"name": "plain", "path": root}]) book = tmp_path / "bookmark.md" book.write_text("\n".join([ "# Bookmark", "", @@ -514,7 +514,7 @@ def test_a_bookmark_that_really_is_empty_still_reports_every_project_never_visit rotation would never start. """ root = _root(tmp_path, "fresh") - register = _projects(tmp_path / "projects.yml", [{"name": "fresh", "path": root}]) + register = _projects(tmp_path / "agents.yml", [{"name": "fresh", "path": root}]) book = tmp_path / "bookmark.md" book.write_text("\n".join([ "# Bookmark", "", @@ -557,7 +557,7 @@ def test_a_stamp_that_does_not_parse_is_unmeasurable_never_unchanged(mod, tmp_pa def test_no_register_and_no_bookmark_are_unmeasurable(mod, tmp_path): for subject in ("register", "bookmark"): - register = _projects(tmp_path / "projects.yml", [{"name": "x", "path": str(tmp_path)}]) + register = _projects(tmp_path / "agents.yml", [{"name": "x", "path": str(tmp_path)}]) book = _bookmark(tmp_path / "bookmark.md", [("`x`", BASE)]) if subject == "register": register.unlink() @@ -572,7 +572,7 @@ def test_the_bookmark_is_not_rewritten(mod, tmp_path): """It measures and never repairs: the bytes are the same after a run.""" root = _root(tmp_path, "stable") _write_memory(Path(root), ".emrg/memory/MEMORY.md", _stamp(mod, 4)) - register = _projects(tmp_path / "projects.yml", [{"name": "stable", "path": root}]) + register = _projects(tmp_path / "agents.yml", [{"name": "stable", "path": root}]) book = _bookmark(tmp_path / "bookmark.md", [("`stable`", BASE)]) before = book.read_bytes() mod.read(register, book) @@ -605,7 +605,7 @@ def test_the_exit_codes_are_0_1_and_2_and_2_is_never_a_pass(mod, tmp_path): """The contract a caller reads: clean, a coverage fault, unmeasurable.""" root = _root(tmp_path, "codes") _write_memory(Path(root), ".emrg/memory/MEMORY.md", _stamp(mod)) - register = _projects(tmp_path / "projects.yml", [{"name": "codes", "path": root}]) + register = _projects(tmp_path / "agents.yml", [{"name": "codes", "path": root}]) clean = _bookmark(tmp_path / "clean.md", [("`codes`", BASE)]) faulty = _bookmark(tmp_path / "faulty.md", [("`codes`", BASE), ("`ghost`", BASE)]) assert mod.main(["--projects", str(register), "--bookmark", str(clean)]) == 0 @@ -617,7 +617,7 @@ def test_the_exit_codes_are_0_1_and_2_and_2_is_never_a_pass(mod, tmp_path): def test_json_is_one_document_that_names_its_subjects(mod, tmp_path, capsys): root = _root(tmp_path, "documented") _write_memory(Path(root), ".emrg/memory/MEMORY.md", _stamp(mod, 1)) - register = _projects(tmp_path / "projects.yml", [{"name": "documented", "path": root}]) + register = _projects(tmp_path / "agents.yml", [{"name": "documented", "path": root}]) book = _bookmark(tmp_path / "bookmark.md", [("`documented`", BASE)]) assert mod.main(["--projects", str(register), "--bookmark", str(book), "--json"]) == 0 document = json.loads(capsys.readouterr().out) @@ -657,7 +657,7 @@ def test_a_second_cycle_section_is_a_fault_the_rows_cannot_show(mod, tmp_path): """ root = _root(tmp_path, "growing") _write_memory(Path(root), ".emrg/memory/MEMORY.md", _stamp(mod)) - register = _projects(tmp_path / "projects.yml", [{"name": "growing", "path": root}]) + register = _projects(tmp_path / "agents.yml", [{"name": "growing", "path": root}]) one = _sectioned(tmp_path / "one.md", [("`growing`", BASE)], 1) two = _sectioned(tmp_path / "two.md", [("`growing`", BASE)], 2) @@ -686,7 +686,7 @@ def test_one_cycle_section_is_the_contract_and_none_is_legitimate(mod, tmp_path) """ root = _root(tmp_path, "shapes") _write_memory(Path(root), ".emrg/memory/MEMORY.md", _stamp(mod)) - register = _projects(tmp_path / "projects.yml", [{"name": "shapes", "path": root}]) + register = _projects(tmp_path / "agents.yml", [{"name": "shapes", "path": root}]) bare = _bookmark(tmp_path / "bare.md", [("`shapes`", BASE)]) noted = tmp_path / "noted.md" noted.write_text("# Bookmark\n\n## Notes\n\n- the qualifier matters\n\n" @@ -710,7 +710,7 @@ def test_a_bookmark_truncated_at_its_section_is_diagnosed_as_truncation(mod, tmp remedies, so the section-carrying case gets its own sentence, and the table-less file with no section keeps the original one. """ - register = _projects(tmp_path / "projects.yml", [{"name": "cut", "path": str(tmp_path)}]) + register = _projects(tmp_path / "agents.yml", [{"name": "cut", "path": str(tmp_path)}]) cut = tmp_path / "cut.md" cut.write_text(f"# Bookmark\n\n{SECTION}\n\nthe rewrite stopped here\n", encoding="utf-8") with pytest.raises(mod.Unmeasurable) as excinfo: @@ -726,3 +726,77 @@ def test_a_bookmark_truncated_at_its_section_is_diagnosed_as_truncation(mod, tmp with pytest.raises(mod.Unmeasurable) as excinfo: mod.read(register, shapeless) assert "another shape" in str(excinfo.value) + + +# ------------------------------------------------- the register's name, mid-move + + +def test_the_default_reads_the_registers_old_name_while_the_move_is_pending( + mod, tmp_path, monkeypatch, capsys +): + """A checkout can run ahead of the daemon that owns the register. + + The rename travels in a release, and installing one is what restarts the daemon + that performs the move, so between the change landing and that release the default + names a file no host has yet. A default alone would answer `not measurable` every + cycle of that window and stop the rotation's clock - which is a silent way for step + 0 to stop being answered, since the step is never skipped. So the default falls + back, and the report names the file it actually read. + """ + root = _root(tmp_path, "pending") + _write_memory(Path(root), ".emrg/memory/MEMORY.md", _stamp(mod, 3)) + # `agents.yml` is never created: the move has not happened on this host yet. + new_name = tmp_path / "agents.yml" + legacy = _projects(tmp_path / "projects.yml", [{"name": "pending", "path": root}]) + book = _bookmark(tmp_path / "bookmark.md", [("`pending`", BASE)]) + monkeypatch.setattr(mod, "DEFAULT_PROJECTS", str(new_name)) + monkeypatch.setattr(mod, "LEGACY_PROJECTS", str(legacy)) + monkeypatch.setattr(mod, "DEFAULT_BOOKMARK", str(book)) + + assert mod.main([]) == 0 + + captured = capsys.readouterr() + assert str(legacy) in captured.out.splitlines()[0], ( + "the first line is the reading's subject; it must name the file that was opened" + ) + assert "does not exist yet" in captured.err and str(legacy) in captured.err + assert "changed since their bookmark: 1" in captured.out + + +def test_an_explicit_register_is_never_second_guessed(mod, tmp_path, monkeypatch, capsys): + """The fallback belongs to the default alone. + + A caller that named a register asked about that register. Falling back on it would + answer a question nobody put, and would hide a mistyped path behind a plausible + reading of a different file. + """ + root = _root(tmp_path, "explicit") + _write_memory(Path(root), ".emrg/memory/MEMORY.md", _stamp(mod, 1)) + decoy = _projects(tmp_path / "projects.yml", [{"name": "decoy", "path": root}]) + wanted = _projects(tmp_path / "named-by-caller.yml", [{"name": "explicit", "path": root}]) + book = _bookmark(tmp_path / "bookmark.md", [("`explicit`", BASE)]) + monkeypatch.setattr(mod, "DEFAULT_PROJECTS", str(tmp_path / "agents.yml")) + monkeypatch.setattr(mod, "LEGACY_PROJECTS", str(decoy)) + + assert mod.main(["--projects", str(wanted), "--bookmark", str(book)]) == 0 + + captured = capsys.readouterr() + assert str(wanted) in captured.out.splitlines()[0] + assert "does not exist yet" not in captured.err + + +def test_with_neither_name_the_reading_is_unmeasurable_never_a_pass( + mod, tmp_path, monkeypatch, capsys +): + """The fallback is not a way to manufacture a reading out of nothing.""" + book = _bookmark(tmp_path / "bookmark.md", [("`gone`", BASE)]) + monkeypatch.setattr(mod, "DEFAULT_PROJECTS", str(tmp_path / "agents.yml")) + monkeypatch.setattr(mod, "LEGACY_PROJECTS", str(tmp_path / "projects.yml")) + monkeypatch.setattr(mod, "DEFAULT_BOOKMARK", str(book)) + + assert mod.main([]) == 2 + + captured = capsys.readouterr() + assert "not measurable" in captured.err + assert "does not exist yet" not in captured.err + diff --git a/tests/test_competition_prompt.py b/tests/test_competition_prompt.py index e9c13769..b42fa20b 100644 --- a/tests/test_competition_prompt.py +++ b/tests/test_competition_prompt.py @@ -59,13 +59,13 @@ def rendered(tmp_path_factory) -> str: The context is the one a real records-driven call produces (a project and nothing else); `config_dir` is redirected at a tmp tree — the shape - `tests/test_evolution_prompt_index_rule.py` uses — so the `projects.yml` read lands + `tests/test_evolution_prompt_index_rule.py` uses — so the `agents.yml` read lands on a file this test made, never on the host's `~/.emrg`. """ tmp_path = tmp_path_factory.mktemp("competition-prompt") project_dir = tmp_path / PROMPT_PROJECT project_dir.mkdir(exist_ok=True) - (tmp_path / "projects.yml").write_text( + (tmp_path / "agents.yml").write_text( yaml.safe_dump([{"name": PROMPT_PROJECT, "path": str(project_dir)}]), encoding="utf-8", ) diff --git a/tests/test_config_write_guard.py b/tests/test_config_write_guard.py index b7a27e8d..8323403f 100644 --- a/tests/test_config_write_guard.py +++ b/tests/test_config_write_guard.py @@ -51,7 +51,7 @@ def test_the_three_real_files_are_named(): """All three, not two: `config.toml` is the one the incident was about.""" home = Path.home() assert conftest._is_real_config_target(home / ".emrg" / "config.toml") - assert conftest._is_real_config_target(home / ".emrg" / "projects.yml") + assert conftest._is_real_config_target(home / ".emrg" / "agents.yml") assert conftest._is_real_config_target(home / ".emrg" / "tasks.yml") @@ -64,7 +64,7 @@ def test_a_scratch_path_is_never_a_real_file(tmp_path): def test_yaml_writer_is_refused(guarded_file): - """`projects.yml` / `tasks.yml`'s own seam, which the guard already had.""" + """`agents.yml` / `tasks.yml`'s own seam, which the guard already had.""" from emrg.server import scheduler as sched_mod with pytest.raises(AssertionError, match=REFUSAL): diff --git a/tests/test_daemon.py b/tests/test_daemon.py index 0e113fe6..ee61ba18 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -166,9 +166,9 @@ def _make_server() -> EmrgServer: """Create a minimal EmrgServer for testing.""" 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/projects.yml (2026-08-13 leak). 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()) / "projects.yml" + server._projects_log = Path(tempfile.mkdtemp()) / "agents.yml" return server @@ -3179,7 +3179,7 @@ def test_list_projects_includes_evolution_workspace(tmp_path, monkeypatch): The filter that used to exclude it was deleted with the `EVOLUTION_CWD` constant (rant 2026-09-29T10:06:33 item 4): what /rant lists is - projects.yml, and a path under `~/.emrg/evolution/` is a project like any + agents.yml, and a path under `~/.emrg/evolution/` is a project like any other. The entry is written in the real evolution-root spelling on purpose — re-adding a workspace filter would drop it and fail the assertion, which is the only reason this test exists. @@ -3188,7 +3188,7 @@ def test_list_projects_includes_evolution_workspace(tmp_path, monkeypatch): server = _make_server() emrg_path = str(Path.home() / ".emrg" / "evolution" / "emrg") - projects_file = tmp_path / "projects.yml" + projects_file = tmp_path / "agents.yml" projects_file.write_text( f"- name: emrg\n path: {emrg_path}\n" "- name: other\n path: /home/u/work/other\n", @@ -3211,9 +3211,9 @@ 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 projects.yml with entries.""" + """Server whose _projects_log points at a tmp agents.yml with entries.""" server = _make_server() - projects_file = tmp_path / "projects.yml" + projects_file = tmp_path / "agents.yml" projects_file.write_text( yaml.safe_dump(entries, allow_unicode=True), encoding="utf-8" ) @@ -3226,7 +3226,7 @@ def _decode_frames(writer: _FakeWriter) -> list[dict]: def test_remove_project_removes_matching_entry(tmp_path): - """Removing an existing project drops only its projects.yml entry.""" + """Removing an existing project drops only its agents.yml entry.""" server, projects_file = _server_with_projects(tmp_path, [ {"name": "alpha", "path": "/home/u/a", "last_active": "2026-08-10T10:00:00"}, {"name": "beta", "path": "/home/u/b", "last_active": "2026-08-10T10:05:00"}, @@ -3276,9 +3276,9 @@ def test_remove_project_preserves_disk_session_data(tmp_path): def test_remove_project_no_projects_file(tmp_path): - """No projects.yml → removed=False without raising.""" + """No agents.yml → removed=False without raising.""" server = _make_server() - server._projects_log = tmp_path / "projects.yml" # never created + server._projects_log = tmp_path / "agents.yml" # never created writer = _FakeWriter() asyncio.run(server._handle_remove_project("alpha", writer)) assert _decode_frames(writer)[0] == { @@ -3287,9 +3287,9 @@ def test_remove_project_no_projects_file(tmp_path): def test_remove_project_corrupt_yaml_reports_error(tmp_path): - """Corrupt projects.yml → removed=False + error, no crash.""" + """Corrupt agents.yml → removed=False + error, no crash.""" server = _make_server() - projects_file = tmp_path / "projects.yml" + projects_file = tmp_path / "agents.yml" projects_file.write_text("a: [unclosed", encoding="utf-8") server._projects_log = projects_file writer = _FakeWriter() @@ -3331,7 +3331,7 @@ def test_list_projects_ordered_by_latest_session_activity(tmp_path, monkeypatch) json.dumps({"session_id": "s-new", "created_at": "2026-08-10T12:00:00"}), encoding="utf-8" ) # 无会话项目 → 排最后(无 latest_session_at) - projects_file = tmp_path / "projects.yml" + projects_file = tmp_path / "agents.yml" projects_file.write_text( f"- name: old\n path: {p_old}\n" f"- name: new\n path: {p_new}\n" @@ -4984,9 +4984,9 @@ def test_the_rant_panel_survives_a_legacy_array_row_and_a_bare_scalar(tmp_path): assert frames[0]["rants"][0]["project"] == "emrg", frames[0] -# ── projects.yml: the registry every client reads first ───────────── +# ── agents.yml: the registry every client reads first ───────────── # -# These are the daemon's four readers of one file, `~/.emrg/projects.yml`, and each of +# 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 @@ -5014,7 +5014,7 @@ def test_the_rant_panel_survives_a_legacy_array_row_and_a_bare_scalar(tmp_path): def test_a_named_pipe_registry_does_not_stop_the_project_listing(tmp_path, monkeypatch): """The first frame every client sends: answered, not blocked on the open.""" server = _make_server() - fifo = tmp_path / "projects.yml" + fifo = tmp_path / "agents.yml" os.mkfifo(fifo) monkeypatch.setattr(server, "_projects_log", fifo) writer = _FakeWriter() @@ -5030,7 +5030,7 @@ def test_a_directory_registry_is_refused_by_its_kind_in_the_listing( tmp_path, monkeypatch, caplog): """The pipe-free half: the refusal names the kind, which is what discriminates.""" server = _make_server() - registry = tmp_path / "projects.yml" + registry = tmp_path / "agents.yml" registry.mkdir() monkeypatch.setattr(server, "_projects_log", registry) writer = _FakeWriter() @@ -5051,7 +5051,7 @@ def test_a_named_pipe_registry_is_neither_read_nor_rewritten_by_touch_project( import stat server = _make_server() - fifo = tmp_path / "projects.yml" + fifo = tmp_path / "agents.yml" os.mkfifo(fifo) monkeypatch.setattr(server, "_projects_log", fifo) @@ -5066,7 +5066,7 @@ def test_a_directory_registry_warns_touch_project_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 / "projects.yml" + registry = tmp_path / "agents.yml" registry.mkdir() monkeypatch.setattr(server, "_projects_log", registry) @@ -5084,7 +5084,7 @@ def test_a_named_pipe_registry_does_not_stop_the_index_backfill(tmp_path, monkey from emrg import sessions_index as sessions_index_mod server = _make_server() - fifo = tmp_path / "projects.yml" + fifo = tmp_path / "agents.yml" os.mkfifo(fifo) monkeypatch.setattr(server, "_projects_log", fifo) seen: dict[str, list[str]] = {} @@ -5105,7 +5105,7 @@ def test_a_directory_registry_is_refused_by_its_kind_in_the_backfill( from emrg import sessions_index as sessions_index_mod server = _make_server() - registry = tmp_path / "projects.yml" + registry = tmp_path / "agents.yml" registry.mkdir() monkeypatch.setattr(server, "_projects_log", registry) monkeypatch.setattr( @@ -5121,7 +5121,7 @@ def test_a_directory_registry_is_refused_by_its_kind_in_the_backfill( def test_a_named_pipe_registry_does_not_stop_remove_project(tmp_path): """The removal answers `removed: false` with its reason, as for a corrupt file.""" server = _make_server() - fifo = tmp_path / "projects.yml" + fifo = tmp_path / "agents.yml" os.mkfifo(fifo) server._projects_log = fifo writer = _FakeWriter() @@ -5139,7 +5139,7 @@ def test_a_directory_registry_is_refused_by_its_kind_in_remove_project( tmp_path, monkeypatch, caplog): """The pipe-free half: the same refusal, observed through the log.""" server = _make_server() - registry = tmp_path / "projects.yml" + registry = tmp_path / "agents.yml" registry.mkdir() monkeypatch.setattr(server, "_projects_log", registry) writer = _FakeWriter() diff --git a/tests/test_evolution_prompt_index_rule.py b/tests/test_evolution_prompt_index_rule.py index 3909466a..ce4d2e63 100644 --- a/tests/test_evolution_prompt_index_rule.py +++ b/tests/test_evolution_prompt_index_rule.py @@ -108,7 +108,7 @@ def rendered(tmp_path_factory) -> str: tmp_path = tmp_path_factory.mktemp("index-rule") project_dir = tmp_path / "demoproj" project_dir.mkdir(exist_ok=True) - (tmp_path / "projects.yml").write_text( + (tmp_path / "agents.yml").write_text( yaml.safe_dump([{"name": "demoproj", "path": str(project_dir)}]), encoding="utf-8" ) original = mod.config_dir diff --git a/tests/test_evolution_prompt_merge_condition.py b/tests/test_evolution_prompt_merge_condition.py index 51567a98..992e9677 100644 --- a/tests/test_evolution_prompt_merge_condition.py +++ b/tests/test_evolution_prompt_merge_condition.py @@ -111,7 +111,7 @@ def rendered(tmp_path_factory) -> str: tmp_path = tmp_path_factory.mktemp("merge-condition") project_dir = tmp_path / "demoproj" project_dir.mkdir(exist_ok=True) - (tmp_path / "projects.yml").write_text( + (tmp_path / "agents.yml").write_text( yaml.safe_dump([{"name": "demoproj", "path": str(project_dir)}]), encoding="utf-8" ) original = mod.config_dir diff --git a/tests/test_evolution_prompt_vote_body.py b/tests/test_evolution_prompt_vote_body.py index 927a7383..cd57e225 100644 --- a/tests/test_evolution_prompt_vote_body.py +++ b/tests/test_evolution_prompt_vote_body.py @@ -88,7 +88,7 @@ def rendered(tmp_path, monkeypatch) -> str: monkeypatch.setattr(mod, "config_dir", lambda: tmp_path) project_dir = tmp_path / "demoproj" project_dir.mkdir(exist_ok=True) - (tmp_path / "projects.yml").write_text( + (tmp_path / "agents.yml").write_text( yaml.safe_dump([{"name": "demoproj", "path": str(project_dir)}]), encoding="utf-8" ) handler = make_handler( diff --git a/tests/test_hermeticity_guard.py b/tests/test_hermeticity_guard.py index b174d92f..13b976e6 100644 --- a/tests/test_hermeticity_guard.py +++ b/tests/test_hermeticity_guard.py @@ -1,10 +1,10 @@ -"""Hermeticity guard tests (2026-08-13 ~/.emrg/projects.yml leak). +"""Hermeticity guard tests (2026-08-13 ~/.emrg/agents.yml leak). Verifies the autouse conftest fixture is wired (both server modules carry the guarded wrapper) and that the discriminator is reliable in BOTH states (#455 lesson): -- tmp projects.yml writes pass through unchanged (negative state); -- the real ~/.emrg/projects.yml / tasks.yml paths raise AssertionError +- tmp agents.yml writes pass through unchanged (negative state); +- the real ~/.emrg/agents.yml / tasks.yml paths raise AssertionError (positive state) — the raise happens before any write, so this test is safe. """ @@ -28,10 +28,10 @@ def test_guard_installed_in_both_modules(): def test_guard_allows_tmp_projects_yml(tmp_path): - """Writes to a tmp projects.yml pass through to the original writer.""" + """Writes to a tmp agents.yml pass through to the original writer.""" import emrg.server.scheduler as sched_mod - target = tmp_path / "projects.yml" + target = tmp_path / "agents.yml" sched_mod.atomic_write_yaml( [{"name": "x", "path": "p", "last_active": "t"}], target, @@ -43,10 +43,10 @@ def test_guard_allows_tmp_projects_yml(tmp_path): def test_guard_rejects_real_projects_yml(): - """Writing the real ~/.emrg/projects.yml is a hard error.""" + """Writing the real ~/.emrg/agents.yml is a hard error.""" import emrg.server.scheduler as sched_mod - real = (Path.home() / ".emrg" / "projects.yml").resolve() + real = (Path.home() / ".emrg" / "agents.yml").resolve() with pytest.raises(AssertionError, match="hermetic"): sched_mod.atomic_write_yaml([], real, prefix=".projects_") diff --git a/tests/test_local_exclude.py b/tests/test_local_exclude.py index e0caa5b0..6ee8dbbe 100644 --- a/tests/test_local_exclude.py +++ b/tests/test_local_exclude.py @@ -286,7 +286,7 @@ 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" / "projects.yml" + server._projects_log = tmp_path / "config" / "agents.yml" server._touch_project(str(repo)) @@ -327,7 +327,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 True, "precondition: real dirt" server = object.__new__(EmrgServer) - server._projects_log = tmp_path / "config" / "projects.yml" + server._projects_log = tmp_path / "config" / "agents.yml" server._touch_project(str(nested)) entries = _exclude_file(repo).read_text(encoding="utf-8").splitlines() @@ -351,12 +351,12 @@ 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" / "projects.yml" + server._projects_log = tmp_path / "config" / "agents.yml" with caplog.at_level("INFO"): server._touch_project(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" assert "now ignores .emrg/" not in caplog.text, caplog.text - registered = (tmp_path / "config" / "projects.yml").read_text(encoding="utf-8") + registered = (tmp_path / "config" / "agents.yml").read_text(encoding="utf-8") assert str(plain) in registered, "the registration half must be unaffected" diff --git a/tests/test_memory_block_leaves_the_prefix.py b/tests/test_memory_block_leaves_the_prefix.py index 287094ca..b04a536c 100644 --- a/tests/test_memory_block_leaves_the_prefix.py +++ b/tests/test_memory_block_leaves_the_prefix.py @@ -47,10 +47,10 @@ def _make_server() -> EmrgServer: """A minimal server, with the projects log pointed away from the real one. The shape `tests/test_memory_row_bound_reach.py::_make_server` uses, for the same - reason: building a system prompt must never write the host's `~/.emrg/projects.yml`. + 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()) / "projects.yml" + server._projects_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 73d3bf2d..edf7bb0e 100644 --- a/tests/test_memory_row_bound_reach.py +++ b/tests/test_memory_row_bound_reach.py @@ -99,10 +99,10 @@ def _make_server() -> EmrgServer: """A minimal server, with the projects log pointed away from the real one. The shape `tests/test_daemon.py::_make_server` uses, for the same reason: building a - system prompt must never write the host's `~/.emrg/projects.yml`. + 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()) / "projects.yml" + server._projects_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 7918e775..923543c2 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()) / "projects.yml" + server._projects_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 931b2c7a..ebbcbf75 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()) / "projects.yml" + server._projects_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 d04522f6..e54f431c 100644 --- a/tests/test_prompt_templates.py +++ b/tests/test_prompt_templates.py @@ -80,7 +80,7 @@ def _write_projects_yml(tmp_path: Path, name: str, entry: dict) -> Path: project_dir.mkdir(exist_ok=True) row = {"path": str(project_dir)} row.update(entry) - (tmp_path / "projects.yml").write_text( + (tmp_path / "agents.yml").write_text( yaml.safe_dump([row]), encoding="utf-8" ) return project_dir diff --git a/tests/test_pwsh_tool_v2.py b/tests/test_pwsh_tool_v2.py index 2e30a78a..ec748194 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()) / "projects.yml" + server._projects_log = Path(tempfile.mkdtemp()) / "agents.yml" return server diff --git a/tests/test_redaction.py b/tests/test_redaction.py index dc527454..c47cb6e8 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 / "projects.yml" + server._projects_log = tmp_path / "agents.yml" return server diff --git a/tests/test_sandbox_escalation.py b/tests/test_sandbox_escalation.py index 7b63e5c5..08b88884 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()) / "projects.yml" + server._projects_log = Path(tempfile.mkdtemp()) / "agents.yml" return server diff --git a/tests/test_sandbox_extra_roots.py b/tests/test_sandbox_extra_roots.py index 65a2137b..3bf7c87e 100644 --- a/tests/test_sandbox_extra_roots.py +++ b/tests/test_sandbox_extra_roots.py @@ -293,9 +293,9 @@ def test_the_protected_file_itself_is_refused_too(outside): """The same rule one level down: a root *is* the file it names.""" ws, _ = outside - verdict = judge_extra_root("~/.emrg/projects.yml", _ws_policy(ws)) + verdict = judge_extra_root("~/.emrg/agents.yml", _ws_policy(ws)) - assert verdict.refusal and "projects.yml" in verdict.refusal + assert verdict.refusal and "agents.yml" in verdict.refusal # ── §2 one derivation point ─────────────────────────────────────────────── diff --git a/tests/test_schedule_window.py b/tests/test_schedule_window.py index 2bf1ed9f..8852db6f 100644 --- a/tests/test_schedule_window.py +++ b/tests/test_schedule_window.py @@ -250,7 +250,7 @@ def sched(tmp_path, monkeypatch): original = mod.config_dir monkeypatch.setattr(mod, "config_dir", lambda: tmp_path) # A registered project, so `task_create` has something to be valid against. - (tmp_path / "projects.yml").write_text( + (tmp_path / "agents.yml").write_text( yaml.safe_dump([{"name": "emrg", "path": str(tmp_path)}]) ) try: diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index a3943283..e33bf568 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -69,8 +69,8 @@ def emit(self, record: logging.LogRecord) -> None: def test_resolve_project_path_found(tmp_path): - """Returns path when project name exists in projects.yml.""" - projects_yml = tmp_path / "projects.yml" + """Returns path when project name exists in agents.yml.""" + projects_yml = tmp_path / "agents.yml" projects_yml.write_text( yaml.safe_dump([ {"name": "emrg", "path": "/home/emrg/src"}, @@ -90,7 +90,7 @@ def test_resolve_project_path_found(tmp_path): def test_resolve_project_path_no_file(tmp_path): - """Returns None when projects.yml doesn't exist.""" + """Returns None when agents.yml doesn't exist.""" from emrg.server import scheduler as mod orig = mod.config_dir try: @@ -102,7 +102,7 @@ def test_resolve_project_path_no_file(tmp_path): def test_resolve_project_path_invalid_yaml(tmp_path): """Returns None for invalid (non-list) YAML.""" - projects_yml = tmp_path / "projects.yml" + projects_yml = tmp_path / "agents.yml" projects_yml.write_text("key: value\n") from emrg.server import scheduler as mod orig = mod.config_dir @@ -219,7 +219,7 @@ def _task_create_fixture(tmp_path, monkeypatch, sched): from emrg.server import scheduler as mod monkeypatch.setattr(mod, "config_dir", lambda: tmp_path) - (tmp_path / "projects.yml").write_text( + (tmp_path / "agents.yml").write_text( yaml.safe_dump([{"name": "proj", "path": str(tmp_path / "proj")}]) ) sched._tasks_file = tmp_path / "tasks.yml" @@ -1727,7 +1727,7 @@ def _make_handler(tmp_path, name="emrg-task", project="emrg", path=None): mod.config_dir = orig_config if path is not None: # What the handler reads is the derived set, so a test that needs a particular - # resolution states it there rather than building a projects.yml to derive it. + # resolution states it there rather than building a agents.yml to derive it. handler._derived["source_dir"] = str(path) handler._derived["project_path"] = str(path) return handler @@ -1781,7 +1781,7 @@ def test_the_seed_returns_when_the_whole_file_is_deleted(tmp_path): def test_ensure_emrg_project_entry_adds_project_entry_when_missing(tmp_path): - """Missing projects.yml emrg entry gets added (fixed path, no network).""" + """Missing agents.yml emrg entry gets added (fixed path, no network).""" from emrg.server import scheduler as mod from emrg.server.scheduler import TaskScheduler @@ -1795,7 +1795,7 @@ def test_ensure_emrg_project_entry_adds_project_entry_when_missing(tmp_path): finally: mod.config_dir = orig_config - projects_yml = tmp_path / "projects.yml" + projects_yml = tmp_path / "agents.yml" assert projects_yml.exists() data = yaml.safe_load(projects_yml.read_text(encoding="utf-8")) assert isinstance(data, list) @@ -1812,7 +1812,7 @@ def test_ensure_emrg_project_entry_preserves_existing_project_entry(tmp_path): # A real existing checkout dir (dev machine) — preserved, never repaired. dev_path = tmp_path / "dev" / "emrg" dev_path.mkdir(parents=True) - projects_yml = tmp_path / "projects.yml" + projects_yml = tmp_path / "agents.yml" projects_yml.write_text(yaml.safe_dump([ {"name": "emrg", "path": str(dev_path), "last_active": "2026-01-01T00:00:00"}, @@ -1836,13 +1836,13 @@ def test_ensure_emrg_project_entry_preserves_existing_project_entry(tmp_path): def test_ensure_emrg_project_entry_repairs_stale_project_entry(tmp_path): """A dead emrg path (deleted pytest-temp dir) is repaired to the canonical workspace (2026-08-12 incident: a test run leaked a pytest temp path into - the real ~/.emrg/projects.yml; the dir is gone after the suite, leaving a + the real ~/.emrg/agents.yml; the dir is gone after the suite, leaving a dangling entry that list_projects/GUI pickers would show forever).""" from emrg.server import scheduler as mod from emrg.server.scheduler import TaskScheduler stale = tmp_path / "gone" / "emrg" # never created → dead path - projects_yml = tmp_path / "projects.yml" + projects_yml = tmp_path / "agents.yml" projects_yml.write_text(yaml.safe_dump([ {"name": "emrg", "path": str(stale), "last_active": "2026-08-12T18:44:50"}, @@ -1872,7 +1872,7 @@ def test_ensure_emrg_project_entry_other_entries_preserved(tmp_path): from emrg.server import scheduler as mod from emrg.server.scheduler import TaskScheduler - projects_yml = tmp_path / "projects.yml" + projects_yml = tmp_path / "agents.yml" projects_yml.write_text(yaml.safe_dump([ {"name": "paper", "path": "/some/paper"}, ])) @@ -2959,7 +2959,7 @@ def test_list_tasks_shows_a_deleted_task_gone_at_once(tmp_path): def _p2_env(tmp_path): """Point config_dir at tmp_path with a registered project.""" from emrg.server import scheduler as mod - projects_yml = tmp_path / "projects.yml" + projects_yml = tmp_path / "agents.yml" projects_yml.write_text(yaml.safe_dump([ {"name": "emrg", "path": str(tmp_path / "emrg")}, {"name": "mem", "path": str(tmp_path / "mem")}, @@ -3013,7 +3013,7 @@ def test_a_record_the_file_carries_runs_even_with_uppercase_in_its_name(tmp_path """The loader must not refuse the content of its own file. `OfficeCLI-opensource-task` is the host's real record, named after the project - `OfficeCLI` in projects.yml. While the name rule accepted lowercase only, the loader + `OfficeCLI` in agents.yml. While the name rule accepted lowercase only, the loader refused it at **every** scheduler tick — measured 2026-09-30: one identical error line ×3963 in `~/.emrg/emrgd.log`, first at 06:51:45 and still firing at 11:19:11, so the task did not run all morning while its record still read `enabled: true`. @@ -4396,7 +4396,7 @@ def test_resolve_project_path_answers_none_for_a_fifo(self, tmp_path, monkeypatc from emrg.server import scheduler as mod self._config(tmp_path, monkeypatch) - self._fifo(tmp_path / "projects.yml") + self._fifo(tmp_path / "agents.yml") assert within(DEADLINE_SECONDS, lambda: mod._resolve_project_path("probe")) is None @_needs_mkfifo @@ -4404,7 +4404,7 @@ def test_load_project_config_answers_empty_for_a_fifo(self, tmp_path, monkeypatc from emrg.server import scheduler as mod self._config(tmp_path, monkeypatch) - self._fifo(tmp_path / "projects.yml") + self._fifo(tmp_path / "agents.yml") assert within(DEADLINE_SECONDS, lambda: mod._load_project_config("probe", str(tmp_path))) == {} @_needs_mkfifo diff --git a/tests/test_shell_tool_mount.py b/tests/test_shell_tool_mount.py index 1a0fef09..8264f906 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()) / "projects.yml" + server._projects_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 673c12c7..e8ab2fbf 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()) / "projects.yml" + server._projects_log = Path(tempfile.mkdtemp()) / "agents.yml" return server diff --git a/tests/test_subagent_tool.py b/tests/test_subagent_tool.py index c52de568..0704def8 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()) / "projects.yml" + server._projects_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_submit_rant_tool.py b/tests/test_submit_rant_tool.py index 34ff35af..57a9a471 100644 --- a/tests/test_submit_rant_tool.py +++ b/tests/test_submit_rant_tool.py @@ -116,11 +116,11 @@ def test_submit_rant_tool_requires_project(): def test_submit_rant_warns_on_unregistered_project(tmp_path, monkeypatch): - """Rant 2026-08-24T10:54:04: project must be a projects.yml short name — + """Rant 2026-08-24T10:54:04: project must be a agents.yml short name — an owner/repo-style name is recorded but flagged with registered candidates so the mistake is visible at submit time.""" monkeypatch.setattr("emrg.config.config_dir", lambda: tmp_path) - (tmp_path / "projects.yml").write_text( + (tmp_path / "agents.yml").write_text( "- name: emrg\n path: /p/emrg\n" "- name: aitokenpool\n path: /p/aitokenpool\n", encoding="utf-8", @@ -144,7 +144,7 @@ def test_submit_rant_warns_on_unregistered_project(tmp_path, monkeypatch): def test_submit_rant_clean_on_registered_project(tmp_path, monkeypatch): """A registered short name submits without the warning.""" monkeypatch.setattr("emrg.config.config_dir", lambda: tmp_path) - (tmp_path / "projects.yml").write_text( + (tmp_path / "agents.yml").write_text( "- name: emrg\n path: /p/emrg\n", encoding="utf-8", ) @@ -1050,7 +1050,7 @@ def test_tool_definition_exposes_actions(): # ── the rant store asks its subject's kind before opening it (issue #2097) ──── # # `rants.jsonl` is the host's feedback channel and the evolution loop's queue, and it is -# opened by path in three places (`_read_rants`, `_write_rants`, the tool's `projects.yml` +# opened by path in three places (`_read_rants`, `_write_rants`, the tool's `agents.yml` # read). `exists()` was the guard, and it is the *wrong* question: it is true for a FIFO, # for a socket and for a device, and an `open()` on any of those blocks — the read until a # writer appears, the write until a reader does, a device never. Nothing is raised, so no @@ -1146,18 +1146,18 @@ def test_the_list_action_on_a_named_pipe_is_a_tool_error_not_a_hang(tmp_path, mo def test_a_named_pipe_projects_yml_yields_no_candidates(tmp_path, monkeypatch): """The advisory read degrades instead of refusing, as it already did for a missing file. - `projects.yml` only feeds a warning about a mis-spelled project name, so turning its + `agents.yml` only feeds a warning about a mis-spelled project name, so turning its unreadability into a hard failure would break `submit` for a reason unrelated to the rant. The control in the same test is a real file: the read still works. """ monkeypatch.setattr("emrg.config.config_dir", lambda: tmp_path) tool = SubmitRantTool() - os.mkfifo(tmp_path / "projects.yml") + os.mkfifo(tmp_path / "agents.yml") assert within(DEADLINE_SECONDS, tool._registered_project_names) == [] - (tmp_path / "projects.yml").unlink() - (tmp_path / "projects.yml").write_text( + (tmp_path / "agents.yml").unlink() + (tmp_path / "agents.yml").write_text( "- name: emrg\n- name: aitokenpool\n", encoding="utf-8") assert sorted(within(DEADLINE_SECONDS, tool._registered_project_names)) == [ @@ -1193,10 +1193,10 @@ def test_projects_yml_absent_or_malformed_yields_no_candidates(tmp_path, monkeyp tool = SubmitRantTool() assert tool._registered_project_names() == [] # absent - (tmp_path / "projects.yml").write_text("just a string\n", encoding="utf-8") + (tmp_path / "agents.yml").write_text("just a string\n", encoding="utf-8") assert tool._registered_project_names() == [] # not a list - (tmp_path / "projects.yml").write_text(": :\n", encoding="utf-8") + (tmp_path / "agents.yml").write_text(": :\n", encoding="utf-8") assert tool._registered_project_names() == [] # unparseable - (tmp_path / "projects.yml").write_text( + (tmp_path / "agents.yml").write_text( "- name: emrg\n- notadict\n- name: ''\n", encoding="utf-8") assert tool._registered_project_names() == ["emrg"] diff --git a/tests/test_tool_argument_domains.py b/tests/test_tool_argument_domains.py index 6e6b58a4..0a3813b1 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 / "projects.yml" + server._projects_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 c3486c6f..2e10bab2 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()) / "projects.yml" + server._projects_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 a35cd492..5b4d7ba5 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 / "projects.yml" + server._projects_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 15869343..b838eed7 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()) / "projects.yml" + server._projects_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 98d2ee5a..ce7321fa 100644 --- a/tests/test_turn_terminal_frame.py +++ b/tests/test_turn_terminal_frame.py @@ -43,10 +43,10 @@ def _server() -> EmrgServer: server = EmrgServer(LlmConfig(base_url="http://localhost", api_key="test")) # Nothing here touches the projects log, but pointing it at a temporary path # is free and keeps a mistaken future edit from writing the host's real - # ~/.emrg/projects.yml (the 2026-08-13 leak). + # ~/.emrg/agents.yml (the 2026-08-13 leak). import tempfile # noqa: PLC0415 — local to the fixture helper - server._projects_log = Path(tempfile.mkdtemp()) / "projects.yml" + server._projects_log = Path(tempfile.mkdtemp()) / "agents.yml" return server diff --git a/tests/test_ws_e2e.py b/tests/test_ws_e2e.py index eb0a01c5..ba6d16a5 100644 --- a/tests/test_ws_e2e.py +++ b/tests/test_ws_e2e.py @@ -106,7 +106,7 @@ async def _boot_server(tmp: Path): daemon_mod.EMRGD_PORT = _test_port connect_mod.EMRGD_PORT = _test_port - # Isolate config dir to tmp (port file, tasks.yml, projects.yml, etc.) + # Isolate config dir to tmp (port file, tasks.yml, agents.yml, etc.) daemon_mod.config_dir = lambda: tmp sched_mod.config_dir = lambda: tmp # scheduler builds its own projects_file (#738) connect_mod.config_dir = lambda: tmp