diff --git a/Agent.md b/Agent.md index fbe7cf0..c5a0508 100644 --- a/Agent.md +++ b/Agent.md @@ -112,7 +112,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` -Python: `uv run pytest tests/ -v` (730) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (736) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (225: 43 daemon_client + 19 conn-manager + 22 app-commands + 104 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index 755ff08..d595a1e 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -1465,7 +1465,7 @@ async def _process_message( elif msg_type == "evolution_summary": # WorkBuddy P3 (rant 21:35): self-evolution visibility. # Low-cost: read evolution log files (~/.emrg/logs/evolution-*.json) - # written by EvolutionHandler; return count + recent N summaries. + # written by TaskHandler; return count + recent N summaries. limit = msg.get("limit", 5) try: logs_dir = config_dir() / "logs" diff --git a/emrg/server/scheduler.py b/emrg/server/scheduler.py index ec32a25..40870af 100644 --- a/emrg/server/scheduler.py +++ b/emrg/server/scheduler.py @@ -54,6 +54,24 @@ } +def _resolve_task_template(task_type: str) -> Path: + """Resolve the prompt template for a task type. + + Built-in TASK_TEMPLATES take priority; custom task types (rant + 2026-08-12T18:14:46 P2) fall back to a user template in + ``~/.emrg/task-templates/.md``, then to the generic + evolution prompt. Pure helper so P2 can unit-test lookup without + a scheduler instance. + """ + builtin = TASK_TEMPLATES.get(task_type) + if builtin: + return Path(__file__).parent / builtin + user_tpl = config_dir() / "task-templates" / f"{task_type}.md" + if user_tpl.exists(): + return user_tpl + return Path(__file__).parent / "evolution_prompt.md" + + def _resolve_project_path(name: str) -> str | None: """Resolve a project name to its path from projects.yml.""" projects_file = config_dir() / "projects.yml" @@ -98,10 +116,10 @@ def _load_project_config(name: str, source_dir: str) -> dict: return {} -# ── EvolutionHandler ──────────────────────────────────────────── +# ── TaskHandler ──────────────────────────────────────────── -class EvolutionHandler: +class TaskHandler: """Self-contained evolution loop for one project (or emrg itself). Each handler runs its own while+sleep(interval) coroutine, @@ -161,15 +179,27 @@ def __init__( path = _resolve_project_path(project_name) if project_name else config.get("path", "") self.project_path = path or name # default to name for emrg itself - # Derive owner/repo/git from path + # Derive owner/repo/git from config override → path git remote → defaults. + # _repo_configured gates the workspace self-heal (rant 2026-08-12T18:14:46): + # any task with a real repo (config owner/repo, or a git remote in its + # project path) gets clone/align self-heal; the emrg evolution task + # always counts as configured (defaults to argszero/emrg). repo_spec = _detect_git_remote(path) if path else "" - if repo_spec and "/" in repo_spec: + cfg_owner = config.get("owner") + cfg_repo = config.get("repo") + if cfg_owner and cfg_repo: + self._owner, self._repo = cfg_owner, cfg_repo + self._repo_url = f"https://github.com/{self._owner}/{self._repo}.git" + self._repo_configured = True + elif repo_spec and "/" in repo_spec: self._owner, self._repo = repo_spec.split("/", 1) self._repo_url = f"https://github.com/{self._owner}/{self._repo}.git" + self._repo_configured = True else: self._owner = self.OWNER self._repo = self.REPO self._repo_url = self.EMRG_REPO_URL + self._repo_configured = project_name == "emrg" self._session_id = f"emrg-evolution-{name}" self._source_dir = path or name # One-shot https-origin probe per handler lifetime (see @@ -305,12 +335,12 @@ def _align_to_installed_version(self, repo_dir: Path) -> None: **win32_no_window_kwargs(), ) logger.info( - "EvolutionHandler[%s]: evolution workspace aligned to %s", + "TaskHandler[%s]: evolution workspace aligned to %s", self.name, tag, ) except (subprocess.CalledProcessError, OSError) as e: logger.warning( - "EvolutionHandler[%s]: tag checkout %s failed (stay on master): %s", + "TaskHandler[%s]: tag checkout %s failed (stay on master): %s", self.name, tag, e, ) @@ -331,7 +361,7 @@ def _ensure_project_entry(self) -> None: entry["last_active"] = datetime.now().isoformat() atomic_write_yaml(entries, projects_file, prefix=".projects_") logger.info( - "EvolutionHandler[%s]: projects.yml self-heal — emrg → %s", + "TaskHandler[%s]: projects.yml self-heal — emrg → %s", self.name, new_path, ) return @@ -342,29 +372,31 @@ def _ensure_project_entry(self) -> None: }) atomic_write_yaml(entries, projects_file, prefix=".projects_") logger.info( - "EvolutionHandler[%s]: projects.yml self-heal — added emrg → %s", + "TaskHandler[%s]: projects.yml self-heal — added emrg → %s", self.name, new_path, ) except (yaml.YAMLError, OSError) as e: logger.warning( - "EvolutionHandler[%s]: projects.yml self-heal failed: %s", + "TaskHandler[%s]: projects.yml self-heal failed: %s", self.name, e, ) def _ensure_evolution_workspace(self) -> bool: - """Self-heal the evolution workspace; returns False to skip the cycle. + """Self-heal the task workspace; returns False to skip the cycle. - Only applies to the EMRG self-evolution task (config.project == emrg). + Applies to any task that has a repo configured (the emrg evolution + task, or a paper/open-source/promote task with config owner/repo or + a git remote in its project path — rant 2026-08-12T18:14:46). Returns True when the workspace is usable (existing dev repo, or a - successful clone into ``~/.emrg/evolution/emrg/``). + successful clone into ``~/.emrg/evolution//``). """ - if self._project_name != "emrg" or self._repo != self.REPO: - return True # paper/open-source/promote tasks: not our concern + if not self._repo_configured: + return True # no repo configured for this task — nothing to self-heal if self._is_usable_git_repo(self._source_dir): self._ensure_origin_reachable() return True # dev machine — use the existing repo as-is repo_url = self._repo_url_from_install_info() or self._repo_url - evolve_dir = EVOLUTION_CWD / self.REPO + evolve_dir = EVOLUTION_CWD / self._repo if evolve_dir.exists(): if self._is_usable_git_repo(str(evolve_dir)): self._source_dir = str(evolve_dir) @@ -372,14 +404,14 @@ def _ensure_evolution_workspace(self) -> bool: self._ensure_origin_reachable() return True logger.warning( - "EvolutionHandler[%s]: %s exists but is not a git repo — " + "TaskHandler[%s]: %s exists but is not a git repo — " "skipping self-heal to avoid data loss", self.name, evolve_dir, ) return False try: logger.info( - "EvolutionHandler[%s]: cloning %s → %s (workspace self-heal)", + "TaskHandler[%s]: cloning %s → %s (workspace self-heal)", self.name, repo_url, evolve_dir, ) self._clone_workspace(repo_url, evolve_dir) @@ -391,7 +423,7 @@ def _ensure_evolution_workspace(self) -> bool: return True except (subprocess.CalledProcessError, OSError) as e: logger.warning( - "EvolutionHandler[%s]: evolution workspace self-heal failed " + "TaskHandler[%s]: evolution workspace self-heal failed " "(network down?): %s — skipping cycle", self.name, e, ) @@ -423,7 +455,7 @@ def _clone_workspace(self, repo_url: str, target: Path) -> None: # NB: `e` is deleted when the except block exits — capture first. reason = (e.stderr.strip() or str(e))[:80] logger.warning( - "EvolutionHandler[%s]: https clone failed (%s) — retrying via SSH", + "TaskHandler[%s]: https clone failed (%s) — retrying via SSH", self.name, reason, ) subprocess.run( @@ -478,7 +510,7 @@ def _ensure_origin_reachable(self) -> None: ) if switch.returncode == 0: logger.warning( - "EvolutionHandler[%s]: https origin unreachable (%s) — " + "TaskHandler[%s]: https origin unreachable (%s) — " "switched origin to %s", self.name, (result.stderr.strip() or "")[:80], ssh_url, ) @@ -491,7 +523,7 @@ def _load_saturation_state(self) -> int: count = data.get("empty_cycles", 0) if count > 0: logger.debug( - "EvolutionHandler[%s]: restored saturation state (%d empty cycles)", + "TaskHandler[%s]: restored saturation state (%d empty cycles)", self.name, count, ) return count @@ -518,7 +550,7 @@ async def run(self) -> None: self._running = True self._start_time = time.time() logger.info( - "EvolutionHandler[%s] started — every %ds", self.name, self.interval + "TaskHandler[%s] started — every %ds", self.name, self.interval ) while self._running: @@ -544,7 +576,7 @@ async def run(self) -> None: self._trigger_event.clear() manual_trigger = True logger.debug( - "EvolutionHandler[%s] manually triggered", self.name + "TaskHandler[%s] manually triggered", self.name ) except asyncio.TimeoutError: # Normal scheduled run @@ -555,28 +587,28 @@ async def run(self) -> None: if manual_trigger: if self._empty_cycles >= self._IDLE_HALT_THRESHOLD: logger.info( - "EvolutionHandler[%s]: resumed via manual trigger " + "TaskHandler[%s]: resumed via manual trigger " "(was in saturation at %d empty cycles)", self.name, self._empty_cycles, ) self._empty_cycles = 0 self._save_saturation_state() - logger.debug("EvolutionHandler[%s] tick", self.name) + logger.debug("TaskHandler[%s] tick", self.name) self._cycle_running = True self._next_run_at = None # running — no next time yet try: await self._run_evolution_cycle() except Exception: logger.warning( - "EvolutionHandler[%s] crashed", self.name, exc_info=True + "TaskHandler[%s] crashed", self.name, exc_info=True ) finally: self._cycle_running = False self._trigger_event.clear() # clear any spurious set during cycle await self._write_final_summary() - logger.info("EvolutionHandler[%s] stopped", self.name) + logger.info("TaskHandler[%s] stopped", self.name) def stop(self) -> None: self._running = False @@ -699,14 +731,14 @@ def _saturation_heartbeat_active(self) -> bool: return False if self._remote_advanced(): logger.info( - "EvolutionHandler[%s]: upstream advanced — resuming normal frequency from saturation", + "TaskHandler[%s]: upstream advanced — resuming normal frequency from saturation", self.name, ) self._empty_cycles = 0 self._save_saturation_state() return False logger.info( - "EvolutionHandler[%s]: saturation (%d empty cycles) — " + "TaskHandler[%s]: saturation (%d empty cycles) — " "running full cycle at heartbeat interval (%ds) — never halting", self.name, self._empty_cycles, self._heartbeat_interval(), ) @@ -718,7 +750,7 @@ async def _run_evolution_cycle(self) -> None: # packaged installs lack a writable git repo; clone on demand. if not self._ensure_evolution_workspace(): logger.warning( - "EvolutionHandler[%s]: workspace not ready — skipping cycle", + "TaskHandler[%s]: workspace not ready — skipping cycle", self.name, ) return @@ -726,7 +758,7 @@ async def _run_evolution_cycle(self) -> None: cycle_time = datetime.now() prompt = self._build_evolution_prompt() logger.info( - "EvolutionHandler[%s]: prompt built (%d chars), connecting ...", + "TaskHandler[%s]: prompt built (%d chars), connecting ...", self.name, len(prompt), ) start_time = cycle_time @@ -736,7 +768,7 @@ async def _run_evolution_cycle(self) -> None: try: ws = await connect_to_server() - logger.info("EvolutionHandler[%s]: connected", self.name) + logger.info("TaskHandler[%s]: connected", self.name) self._connect_failures = 0 except (ConnectionRefusedError, FileNotFoundError, OSError) as e: # G129 (rant 2026-08-09T08:03:46): 连接失败不得静默吞掉——GUI 测试曾把 @@ -747,7 +779,7 @@ async def _run_evolution_cycle(self) -> None: port_path = config_dir() / "emrgd.port" if self._connect_failures >= self._CONNECT_FAIL_ALERT: logger.error( - "EvolutionHandler[%s]: cannot connect for %d consecutive cycles " + "TaskHandler[%s]: cannot connect for %d consecutive cycles " "(%s) — daemon unreachable. Check %s (may have been overwritten " "by GUI tests or stale after daemon restart); run 'emrg server' " "or restart the daemon to recover.", @@ -755,7 +787,7 @@ async def _run_evolution_cycle(self) -> None: ) else: logger.warning( - "EvolutionHandler[%s]: cannot connect (%d/%d): %s", + "TaskHandler[%s]: cannot connect (%d/%d): %s", self.name, self._connect_failures, self._CONNECT_FAIL_ALERT, e, ) return @@ -798,12 +830,12 @@ async def _run_evolution_cycle(self) -> None: truncated = "exceeded" in content.lower() if truncated: logger.warning( - "EvolutionHandler[%s] truncated (max tool rounds, tools=%d, duration=%ds)", + "TaskHandler[%s] truncated (max tool rounds, tools=%d, duration=%ds)", self.name, tool_count, duration, ) else: logger.info( - "EvolutionHandler[%s] complete (tools=%d, duration=%ds)", + "TaskHandler[%s] complete (tools=%d, duration=%ds)", self.name, tool_count, duration, ) break @@ -815,12 +847,12 @@ async def _run_evolution_cycle(self) -> None: if isinstance(resp_error, str): error = str(resp_error) logger.warning( - "EvolutionHandler[%s] server error: %s", + "TaskHandler[%s] server error: %s", self.name, error, ) break except Exception as e: - logger.exception("EvolutionHandler[%s] error", self.name) + logger.exception("TaskHandler[%s] error", self.name) error = str(e) finally: try: @@ -844,7 +876,7 @@ async def _run_evolution_cycle(self) -> None: self._empty_cycles += 1 self._save_saturation_state() logger.debug( - "EvolutionHandler[%s]: empty cycle #%d (HEAD=%s)", + "TaskHandler[%s]: empty cycle #%d (HEAD=%s)", self.name, self._empty_cycles, git_head_after[:8], ) else: @@ -853,7 +885,7 @@ async def _run_evolution_cycle(self) -> None: if error: reason = f"aborted cycle ({error[:80]})" logger.info( - "EvolutionHandler[%s]: %s, resetting empty streak", + "TaskHandler[%s]: %s, resetting empty streak", self.name, reason, ) self._empty_cycles = 0 @@ -865,7 +897,7 @@ async def _run_evolution_cycle(self) -> None: # busy" aborts observed when interactive sessions hold the daemon. if error: logger.warning( - "EvolutionHandler[%s]: cycle aborted (%s) — not counted as evolution", + "TaskHandler[%s]: cycle aborted (%s) — not counted as evolution", self.name, error[:200], ) return @@ -984,23 +1016,23 @@ class TaskScheduler: """ HANDLERS: dict[str, type] = { - "evolution": EvolutionHandler, - "paper": EvolutionHandler, # same handler, different template - "open-source": EvolutionHandler, # same handler, different template - "promote": EvolutionHandler, # same handler, different template + "evolution": TaskHandler, + "paper": TaskHandler, # same handler, different template + "open-source": TaskHandler, # same handler, different template + "promote": TaskHandler, # same handler, different template } def __init__(self, identity: InstanceIdentity) -> None: self.identity = identity self._tasks_file = config_dir() / "tasks.yml" - self._handlers: list[EvolutionHandler] = [] + self._handlers: list[TaskHandler] = [] self._coros: list[asyncio.Task] = [] def load_and_start(self) -> list[asyncio.Task]: """Load tasks.yml, start all enabled tasks, return coroutine list.""" # Self-heal: packaged installs may lack the emrg self-evolution task. # This must run here (not inside the handler) because a missing task - # means no EvolutionHandler is ever started (rant 20:42 方案 C). + # means no TaskHandler is ever started (rant 20:42 方案 C). self._ensure_self_evolution_task() tasks_config = self._load_tasks() @@ -1020,8 +1052,7 @@ def load_and_start(self) -> list[asyncio.Task]: cfg["type"], cfg["name"], ) continue - template_name = TASK_TEMPLATES.get(cfg["type"], "evolution_prompt.md") - template_path = Path(__file__).parent / template_name + template_path = _resolve_task_template(cfg["type"]) handler = handler_cls( name=cfg["name"], config=cfg.get("config", {}), @@ -1128,7 +1159,7 @@ def _ensure_self_evolution_task(self) -> None: """Ensure projects.yml has an emrg entry and tasks.yml has the task. Packaged installs (or first runs) may lack tasks.yml entirely, or lack - the emrg-task entry. Without it, no EvolutionHandler is ever created, + the emrg-task entry. Without it, no TaskHandler is ever created, so the workspace self-heal (which lives inside the handler) cannot run. The projects.yml emrg entry is ensured here too (rant 02:58): the only diff --git a/tests/test_daemon.py b/tests/test_daemon.py index 99a2efd..fb22505 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -17,16 +17,16 @@ from emrg.config import LlmConfig from emrg.protocol import InstanceIdentity from emrg.server.daemon import EmrgServer -from emrg.server.scheduler import EvolutionHandler, TaskScheduler +from emrg.server.scheduler import TaskHandler, TaskScheduler from emrg.session import Session -# ── EvolutionHandler._build_evolution_prompt ───────────────────── +# ── TaskHandler._build_evolution_prompt ───────────────────── def test_build_prompt_emrg_self(): """Builds prompt for emrg self-evolution.""" - handler = EvolutionHandler( + handler = TaskHandler( name="emrg", config={"path": "/tmp/emrg"}, interval=1800, identity=InstanceIdentity(instance_id="test-id", host_name="testhost"), ) @@ -45,7 +45,7 @@ def test_build_prompt_emrg_self(): def test_build_prompt_with_project(): """Builds prompt for a custom project — derives owner/repo via git remote.""" - handler = EvolutionHandler( + handler = TaskHandler( name="myproject", config={"path": "/home/user/src/myproject"}, interval=1800, identity=InstanceIdentity(instance_id="test-id", host_name="testhost"), ) @@ -66,7 +66,7 @@ def test_build_prompt_all_variables_substituted(): """No raw template placeholders ({var}) should remain in output.""" import re - handler = EvolutionHandler( + handler = TaskHandler( name="emrg", config={"path": "/tmp/emrg"}, interval=1800, identity=InstanceIdentity(instance_id="test-id", host_name="testhost"), ) @@ -83,7 +83,7 @@ def test_build_prompt_step22_uses_fetch_head(): stripped `remote.origin.fetch`), where `git log origin/master` fails with "unknown revision" (observed 2026-08-08, cycles 09:15 & 09:30). """ - handler = EvolutionHandler( + handler = TaskHandler( name="emrg", config={"path": "/tmp/emrg"}, interval=1800, identity=InstanceIdentity(instance_id="test-id", host_name="testhost"), ) diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 8441ee4..9a15cfc 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -11,7 +11,7 @@ from emrg.protocol import InstanceIdentity from emrg.server.scheduler import ( - EvolutionHandler, + TaskHandler, TaskScheduler, _resolve_project_path, ) @@ -372,7 +372,7 @@ def test_task_templates_cover_all_handlers(): def test_load_and_start_promote_task(tmp_path): - """Promote tasks start an EvolutionHandler with the promote template.""" + """Promote tasks start an TaskHandler with the promote template.""" tasks_yml = tmp_path / "tasks.yml" tasks_yml.write_text(yaml.safe_dump([ {"name": "olr-promote", "type": "promote", @@ -403,12 +403,56 @@ async def _run(): c.cancel() -# ── EvolutionHandler core ───────────────────────────────────────── +def test_resolve_task_template_custom_user_file(tmp_path): + """Custom type with a user template resolves to ~/.emrg/task-templates/.md.""" + from emrg.server import scheduler as mod + (tmp_path / "task-templates").mkdir() + user_tpl = tmp_path / "task-templates" / "report.md" + user_tpl.write_text("# Custom {{ instance_id }}\n", encoding="utf-8") + + orig_config = mod.config_dir + mod.config_dir = lambda: tmp_path + try: + assert mod._resolve_task_template("report") == user_tpl + finally: + mod.config_dir = orig_config + + +def test_resolve_task_template_custom_missing_falls_back(tmp_path): + """Custom type without a user template falls back to evolution_prompt.md.""" + from emrg.server import scheduler as mod + orig_config = mod.config_dir + mod.config_dir = lambda: tmp_path + try: + resolved = mod._resolve_task_template("no-such-type") + assert resolved.name == "evolution_prompt.md" + finally: + mod.config_dir = orig_config + + +def test_resolve_task_template_builtin_unchanged(tmp_path): + """Built-in types keep resolving to emrg/server/.md regardless of user dir.""" + from emrg.server import scheduler as mod + (tmp_path / "task-templates").mkdir() + (tmp_path / "task-templates" / "evolution.md").write_text( + "# evil override\n", encoding="utf-8" + ) + orig_config = mod.config_dir + mod.config_dir = lambda: tmp_path + try: + resolved = mod._resolve_task_template("evolution") + assert resolved.name == "evolution_prompt.md" + assert "task-templates" not in str(resolved) + finally: + mod.config_dir = orig_config + + +# ── TaskHandler core ───────────────────────────────────────── def test_evolution_handler_project_path_fallback(): """Without config.project or config.path, name is the fallback path.""" - handler = EvolutionHandler( + handler = TaskHandler( name="emrg", config={}, interval=1800, @@ -419,7 +463,7 @@ def test_evolution_handler_project_path_fallback(): def test_evolution_handler_project_path_from_config(): """config.path is used when config.project is empty.""" - handler = EvolutionHandler( + handler = TaskHandler( name="emrg", config={"path": "/custom/path"}, interval=1800, @@ -430,7 +474,7 @@ def test_evolution_handler_project_path_from_config(): def test_evolution_handler_stop(): """stop() sets _running to False.""" - handler = EvolutionHandler( + handler = TaskHandler( name="test", config={}, interval=60, identity=InstanceIdentity(), ) @@ -441,7 +485,7 @@ def test_evolution_handler_stop(): def test_evolution_handler_default_owner(): """When no git remote is detectable, falls back to EMRG defaults.""" - handler = EvolutionHandler( + handler = TaskHandler( name="unknown-project", config={"path": "/nonexistent/path"}, interval=1800, @@ -450,6 +494,46 @@ def test_evolution_handler_default_owner(): assert handler._owner == "argszero" assert handler._repo == "emrg" assert handler._repo_url == "https://github.com/argszero/emrg.git" + # No repo configured (non-emrg project, no remote, no config) → no self-heal + assert handler._repo_configured is False + + +def test_task_handler_emrg_configured_by_default(): + """The emrg evolution task is always repo-configured (defaults to argszero/emrg).""" + handler = TaskHandler( + name="emrg-task", + config={"project": "emrg"}, + interval=1800, + identity=InstanceIdentity(), + ) + assert handler._repo_configured is True + assert handler._repo == "emrg" + + +def test_task_handler_repo_configured_from_config(): + """config owner/repo overrides the default and enables self-heal (rant 18:14:46 P1).""" + handler = TaskHandler( + name="paper-x", + config={"project": "some-proj", "owner": "acme", "repo": "paper-x"}, + interval=1800, + identity=InstanceIdentity(), + ) + assert handler._repo_configured is True + assert handler._owner == "acme" + assert handler._repo == "paper-x" + assert handler._repo_url == "https://github.com/acme/paper-x.git" + + +def test_task_handler_no_repo_skips_self_heal(): + """Non-emrg task without any repo config → _ensure_evolution_workspace no-ops.""" + handler = TaskHandler( + name="docs-task", + config={"path": "/tmp/plain-folder"}, + interval=1800, + identity=InstanceIdentity(), + ) + assert handler._repo_configured is False + assert handler._ensure_evolution_workspace() is True # skip, not block def test_task_scheduler_total_evolutions(): @@ -457,8 +541,8 @@ def test_task_scheduler_total_evolutions(): from emrg.protocol import EvolutionLog sched = TaskScheduler(InstanceIdentity()) - h1 = EvolutionHandler(name="a", config={}, interval=60, identity=InstanceIdentity()) - h2 = EvolutionHandler(name="b", config={}, interval=60, identity=InstanceIdentity()) + h1 = TaskHandler(name="a", config={}, interval=60, identity=InstanceIdentity()) + h2 = TaskHandler(name="b", config={}, interval=60, identity=InstanceIdentity()) sched._handlers = [h1, h2] assert sched.total_evolutions() == 0 @@ -493,11 +577,11 @@ def test_paper_template_renders_with_context(): def _make_handler(tmp_path, name="emrg-task", project="emrg", path=None): - """Build an EvolutionHandler pointed at a tmp config dir.""" + """Build an TaskHandler pointed at a tmp config dir.""" from emrg.server import scheduler as mod orig_config = mod.config_dir mod.config_dir = lambda: tmp_path - handler = EvolutionHandler( + handler = TaskHandler( name=name, config={"project": project} if project else {}, interval=60, @@ -750,7 +834,7 @@ def test_ensure_evolution_workspace_dev_repo_untouched(tmp_path): orig_config = mod.config_dir mod.config_dir = lambda: tmp_path try: - handler = EvolutionHandler( + handler = TaskHandler( name="emrg-task", config={"project": "emrg"}, interval=60, @@ -1040,7 +1124,7 @@ def test_remote_advanced_ssh_fallback_when_https_blocked(tmp_path): assert "git@github.com:argszero/emrg.git" in ls_calls[1][0] -# ── EvolutionHandler cycle truncation detection ────────────────── +# ── TaskHandler cycle truncation detection ────────────────── # mem-repo lesson (tool-call truncation must be flagged, not silently # treated as a successful/empty cycle — #523 applied it to the chat UI; # this covers EMRG's own evolution task loop). @@ -1202,9 +1286,9 @@ async def _fake_connect(): def test_connect_backoff_zero_failures_returns_interval(): """No consecutive failures → normal interval (no backoff).""" - from emrg.server.scheduler import EvolutionHandler + from emrg.server.scheduler import TaskHandler - handler = EvolutionHandler( + handler = TaskHandler( name="emrg-task", config={"project": "emrg"}, interval=60, identity=InstanceIdentity(), ) @@ -1214,9 +1298,9 @@ def test_connect_backoff_zero_failures_returns_interval(): def test_connect_backoff_grows_exponentially_capped(tmp_path): """Consecutive failures grow the wait, capped at 10 minutes.""" - from emrg.server.scheduler import EvolutionHandler + from emrg.server.scheduler import TaskHandler - handler = EvolutionHandler( + handler = TaskHandler( name="emrg-task", config={"project": "emrg"}, interval=60, identity=InstanceIdentity(), ) @@ -1231,9 +1315,9 @@ def test_connect_backoff_grows_exponentially_capped(tmp_path): def test_connect_backoff_floor_30s_for_small_interval(tmp_path): """Backoff never drops below 30s even for very fast intervals.""" - from emrg.server.scheduler import EvolutionHandler + from emrg.server.scheduler import TaskHandler - handler = EvolutionHandler( + handler = TaskHandler( name="emrg-task", config={"project": "emrg"}, interval=10, identity=InstanceIdentity(), )