From 6ea3fa13a3686e6fb208db2694d2af2e1fc770f9 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Fri, 7 Aug 2026 22:41:20 +0800 Subject: [PATCH] emrg: exclude aborted evolution cycles from count and idle-halt backoff --- Agent.md | 2 +- README.md | 2 +- emrg/server/scheduler.py | 21 ++++++++++++++++++--- tests/test_scheduler.py | 23 +++++++++++++++++++++++ 4 files changed, 43 insertions(+), 5 deletions(-) diff --git a/Agent.md b/Agent.md index 6c8c0ce..7c0d891 100644 --- a/Agent.md +++ b/Agent.md @@ -93,7 +93,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` (572) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (574) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (93: 22 daemon_client + 22 app-commands + 24 renderer smoke + 15 i18n + 7 integration + 3 commands) — 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/README.md b/README.md index 7c40dc4..39ef362 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,7 @@ EMRG doesn't just keep up — it catches up on its own. git clone https://github.com/argszero/emrg.git cd emrg uv sync # install deps -uv run pytest tests/ -v # run tests (currently 572 items) +uv run pytest tests/ -v # run tests (currently 574 items) uv run python -m emrg # launch TUI # CI includes actionlint workflow gate (#444): workflow parse errors fail PR CI diff --git a/emrg/server/scheduler.py b/emrg/server/scheduler.py index 409a694..fd13167 100644 --- a/emrg/server/scheduler.py +++ b/emrg/server/scheduler.py @@ -666,9 +666,13 @@ async def _run_evolution_cycle(self) -> None: # Detect empty cycles: git HEAD unchanged → no work was done. # A truncated cycle is NOT empty — the agent wanted to work but hit # the tool-round cap; counting it would wrongly back off the handler. + # An aborted cycle (server error like "session busy", or an exception) + # is NOT empty either — the agent was blocked before reaching an NTE + # conclusion; counting it would also advance the idle-halt backoff. git_head_after = self._get_git_head() if ( - not truncated + not error + and not truncated and git_head_before and git_head_after and git_head_before == git_head_after ): @@ -681,6 +685,8 @@ async def _run_evolution_cycle(self) -> None: else: if self._empty_cycles > 0: reason = "truncated cycle" if truncated else "git HEAD changed" + if error: + reason = f"aborted cycle ({error[:80]})" logger.info( "EvolutionHandler[%s]: %s, resetting empty streak", self.name, reason, @@ -688,6 +694,17 @@ async def _run_evolution_cycle(self) -> None: self._empty_cycles = 0 self._save_saturation_state() + # Aborted cycles are not evolutions: no log file, no count. Writing + # them would inflate the evolution count (GUI growth card / toast, + # evolution_summary) with work that never happened — the "session + # busy" aborts observed when interactive sessions hold the daemon. + if error: + logger.warning( + "EvolutionHandler[%s]: cycle aborted (%s) — not counted as evolution", + self.name, error[:200], + ) + return + cycle_ts = cycle_time.isoformat() impact = [ f"evolution-cycle-{cycle_ts}-{'truncated' if truncated else 'complete'}", @@ -695,8 +712,6 @@ async def _run_evolution_cycle(self) -> None: ] if truncated: impact.append("truncated=max-tool-rounds") - if error: - impact.append(f"error={error[:200]}") log = EvolutionLog( timestamp=cycle_ts, diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 11109c6..dc91b0f 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -874,6 +874,29 @@ def test_evolution_cycle_complete_unchanged_head_still_empty(tmp_path): assert "truncated=max-tool-rounds" not in impact, impact +def test_evolution_cycle_aborted_error_not_counted(tmp_path): + """Server error frame (e.g. 'session busy') → no evolution log, no count.""" + handler, captured = _make_cycle_handler(tmp_path, frames=[ + {"error": "session busy"}, + ]) + asyncio.run(handler._run_evolution_cycle()) + assert "log" not in captured, "aborted cycle must not write an evolution log" + assert handler.evolutions == [], "aborted cycle must not append to evolutions" + assert handler._empty_cycles == 0, \ + "aborted cycle must not advance the idle-halt backoff (agent never ran)" + + +def test_evolution_cycle_aborted_resets_empty_streak(tmp_path): + """Aborted cycle resets a pre-existing empty streak (blocked ≠ NTE).""" + handler, captured = _make_cycle_handler(tmp_path, frames=[ + {"error": "session busy"}, + ]) + handler._empty_cycles = 5 + asyncio.run(handler._run_evolution_cycle()) + assert "log" not in captured + assert handler._empty_cycles == 0, "abort resets the streak (not a real empty cycle)" + + # ── Saturation halt auto-resume on upstream advance ─────────────── # The halt skips scheduled runs entirely, so a halted handler can never # detect a HEAD change itself (only /trigger could resume it). If every