Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 路径不受影响)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
64 changes: 56 additions & 8 deletions emrg/server/scheduler.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
Expand DownExpand Up@@ -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.
Expand Down
66 changes: 65 additions & 1 deletion tests/test_scheduler.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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")))
Expand All@@ -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` → "<sha>\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")
Expand DownExpand Up@@ -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
Loading