diff --git a/Agent.md b/Agent.md index 06c0ae44..8da596bb 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` (495) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (499) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (88: 22 daemon_client + 22 app-commands + 19 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 f4f71842..c4e266b1 100644 --- a/README.md +++ b/README.md @@ -276,7 +276,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 495 items) +uv run pytest tests/ -v # run tests (currently 499 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 fd2f0641..424c30ff 100644 --- a/emrg/server/scheduler.py +++ b/emrg/server/scheduler.py @@ -440,13 +440,7 @@ async def run(self) -> None: ) self._empty_cycles = 0 self._save_saturation_state() - elif self._empty_cycles >= self._IDLE_HALT_THRESHOLD: - logger.info( - "EvolutionHandler[%s]: saturation halt — " - "skipping scheduled run (%d empty cycles). " - "Use /trigger to resume.", - self.name, self._empty_cycles, - ) + elif self._saturation_halt_active(): continue logger.debug("EvolutionHandler[%s] tick", self.name) @@ -502,8 +496,62 @@ def status(self) -> dict: "interval": self.interval, } + def _remote_advanced(self) -> bool: + """True if origin/master differs from the local HEAD (new upstream work). + + A saturation-halted handler never runs scheduled cycles, so it can + never detect a HEAD change on its own — only a manual /trigger + could resume it. If every instance halted during an idle stretch, + new upstream work (PRs/commits from other instances or the host) + would go unnoticed indefinitely. This cheap check (one ``git + ls-remote`` — no fetch, no working-tree mutation) lets the halt + auto-resume on genuine upstream activity. + """ + try: + local = self._get_git_head() + if not local: + return False + result = subprocess.run( + ["git", "ls-remote", "origin", "master"], + cwd=self._source_dir, + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode != 0: + return False + remote = result.stdout.strip().split() + return bool(remote) and remote[0] != local + except Exception: + return False + + def _saturation_halt_active(self) -> bool: + """Whether a scheduled tick should be skipped due to saturation halt. + + Extracted from the run loop so the halt decision is testable: + at/above the threshold the tick is skipped UNLESS the upstream + remote advanced (auto-resume: reset the counter and run the cycle, + so a halted handler does not miss new work forever). + """ + if self._empty_cycles < self._IDLE_HALT_THRESHOLD: + return False + if self._remote_advanced(): + logger.info( + "EvolutionHandler[%s]: upstream advanced — resuming from saturation halt", + self.name, + ) + self._empty_cycles = 0 + self._save_saturation_state() + return False + logger.info( + "EvolutionHandler[%s]: saturation halt — " + "skipping scheduled run (%d empty cycles). " + "Use /trigger to resume.", + self.name, self._empty_cycles, + ) + return True + async def _run_evolution_cycle(self) -> None: - """Connect to server, send evolution prompt, read streaming response.""" # Self-heal the evolution workspace first (rant 20:42 方案 C): # packaged installs lack a writable git repo; clone on demand. diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 41635853..7d84cd46 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -496,11 +496,12 @@ def _make_handler(tmp_path, name="emrg-task", project="emrg", path=None): class FakeGitRun: """Controllable subprocess.run fake for git commands.""" - def __init__(self, git_repo=True, tags="v0.2.7", clone_fails=False): + def __init__(self, git_repo=True, tags="v0.2.7", clone_fails=False, remote_head="abc123"): self.calls = [] self.git_repo = git_repo self.tags = tags self.clone_fails = clone_fails + self.remote_head = remote_head def __call__(self, cmd, *args, **kwargs): self.calls.append((list(cmd), kwargs.get("cwd"))) @@ -512,6 +513,9 @@ def __call__(self, cmd, *args, **kwargs): return _R(0, "true\n" if self.git_repo else "false\n") if "HEAD" in cmd: return _R(0, "abc123\n") + if sub == "ls-remote": + # `git ls-remote origin master` → "\trefs/heads/master" + return _R(0, f"{self.remote_head}\trefs/heads/master\n") if sub == "clone": if self.clone_fails: raise _CalledProcessErrorStub("clone failed") @@ -792,3 +796,63 @@ def test_evolution_cycle_complete_unchanged_head_still_empty(tmp_path): impact = captured["log"].impact assert any(i.endswith("-complete") for i in impact), impact assert "truncated=max-tool-rounds" not in impact, impact + + +# ── 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 +# instance halted during an idle stretch, new upstream work would go +# unnoticed — the halt must auto-resume when origin/master advances. + +def test_saturation_halt_active_true_when_remote_unchanged(tmp_path): + """At/above threshold + unchanged remote → tick skipped (halt stays).""" + from emrg.server import scheduler as mod + + handler = _make_handler(tmp_path, project="", path=str(tmp_path)) + handler._empty_cycles = 30 # == _IDLE_HALT_THRESHOLD + fake = FakeGitRun(remote_head="abc123") # == local HEAD → not advanced + orig_run = mod.subprocess.run + mod.subprocess.run = fake + try: + assert handler._saturation_halt_active() is True + assert handler._empty_cycles == 30 # counter untouched + finally: + mod.subprocess.run = orig_run + + +def test_saturation_halt_resumes_and_resets_when_remote_advanced(tmp_path): + """At/above threshold + remote advanced → resume, counter reset to 0.""" + from emrg.server import scheduler as mod + + handler = _make_handler(tmp_path, project="", path=str(tmp_path)) + handler._empty_cycles = 30 + fake = FakeGitRun(remote_head="9f8e7d6") # != local abc123 → advanced + orig_run = mod.subprocess.run + mod.subprocess.run = fake + try: + assert handler._saturation_halt_active() is False + assert handler._empty_cycles == 0 # reset → scheduled runs resume + finally: + mod.subprocess.run = orig_run + + +def test_saturation_halt_active_false_below_threshold(tmp_path): + """Below threshold → never halt (remote state irrelevant).""" + handler = _make_handler(tmp_path, project="", path=str(tmp_path)) + handler._empty_cycles = 10 + assert handler._saturation_halt_active() is False + assert handler._empty_cycles == 10 + + +def test_remote_advanced_false_without_git_repo(tmp_path): + """Not a git repo / ls-remote fails → False (stay halted, no crash).""" + from emrg.server import scheduler as mod + + handler = _make_handler(tmp_path, project="", path=str(tmp_path)) + fake = FakeGitRun(git_repo=False) + orig_run = mod.subprocess.run + mod.subprocess.run = fake + try: + assert handler._remote_advanced() is False + finally: + mod.subprocess.run = orig_run