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 number Diff line number Diff 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` (575) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (589) — 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 路径不受影响)
Expand Down
2 changes: 1 addition & 1 deletion README.cn.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ EMRG 不只是追赶——它自己追上来。
git clone https://github.com/argszero/emrg.git
cd emrg
uv sync # 安装依赖
uv run pytest tests/ -v # 跑测试(当前 548 项)
uv run pytest tests/ -v # 跑测试(当前 589 项)
uv run python -m emrg # 启动 TUI
# CI 含 actionlint workflow 门禁(#444):workflow 解析错误在 PR 即失败

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 575 items)
uv run pytest tests/ -v # run tests (currently 589 items)
uv run python -m emrg # launch TUI
# CI includes actionlint workflow gate (#444): workflow parse errors fail PR CI

Expand Down
60 changes: 60 additions & 0 deletions emrg/server/git_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,66 @@ def parse_gh_auth_user(output: str) -> str | None:
return match.group(1) if match else None


# ── HTTPS→SSH fallback for blocked github.com:443 (2026-08-08) ───
#
# Some networks block github.com:443 (HTTPS git transport) while SSH
# (port 22) and the github.com/ghapi REST endpoint stay reachable. Observed
# on the packaged host: `git pull` hangs ~75 s then fails with "Failed to
# connect to github.com port 443", while `ssh -T git@github.com` succeeds.
# A fresh `git clone` or any pull/push against an https origin then fails
# and the evolution workspace never syncs. These helpers convert a
# github.com https URL to its SSH form and recognise connection-type git
# errors, so the scheduler can retry via SSH. Deliberately narrow: auth
# failures / 404s / repo-specific errors never trigger a switch.

_HTTPS_GITHUB_RE = re.compile(r"^https://github\.com/([^/]+)/([^/]+?)(?:\.git)?$")

_CONNECTION_ERROR_MARKERS = (
"failed to connect",
"couldn't connect",
"could not connect",
"connection refused",
"connection timed out",
"operation timed out",
"could not resolve host",
"network is unreachable",
"unable to access",
"tls handshake timeout",
)


def https_to_ssh_url(url: str) -> str | None:
"""Convert a github.com https URL to its SSH form, or None.

``https://github.com/owner/repo.git`` → ``git@github.com:owner/repo.git``
Returns None for non-github / non-https URLs (SSH URLs, enterprise
hosts, local paths) — callers must not switch those.
"""
match = _HTTPS_GITHUB_RE.match((url or "").strip())
if not match:
return None
return f"git@github.com:{match.group(1)}/{match.group(2)}.git"


def is_git_connection_error(stderr: str) -> bool:
"""True when git stderr indicates a network/connection failure.

Does NOT match auth errors ("Authentication failed", "Permission
denied (publickey)"), missing repos ("Repository not found") or other
non-connection failures — switching the remote would not fix those.
"""
text = (stderr or "").lower()
return any(marker in text for marker in _CONNECTION_ERROR_MARKERS)


def git_origin_url(cwd: str) -> str:
"""Return the raw origin URL for a repo, '' when absent/unreadable."""
result = git_cmd("remote", "get-url", "origin", cwd=cwd, timeout=5)
if result.returncode == 0:
return result.stdout.strip()
return ""


def _detect_git_remote(cwd: str) -> str:
"""Detect the origin remote (owner/repo) from a git repository.

Expand Down
117 changes: 106 additions & 11 deletions emrg/server/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
from emrg.server.git_utils import (
INSTALL_INFO,
_detect_git_remote,
git_origin_url,
https_to_ssh_url,
is_git_connection_error,
no_prompt_env,
resolve_git_gh,
)
Expand Down Expand Up @@ -162,6 +165,9 @@ def __init__(
self._repo_url = self.EMRG_REPO_URL
self._session_id = f"emrg-evolution-{name}"
self._source_dir = path or name
# One-shot https-origin probe per handler lifetime (see
# _ensure_origin_reachable) — avoids re-probing every cycle.
self._origin_probed = False

def _get_git_head(self) -> str | None:
"""Return current git HEAD hash, or None if not a git repo."""
Expand Down Expand Up @@ -342,13 +348,15 @@ def _ensure_evolution_workspace(self) -> bool:
if self._project_name != "emrg" or self._repo != self.REPO:
return True # paper/open-source/promote tasks: not our concern
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
if evolve_dir.exists():
if self._is_usable_git_repo(str(evolve_dir)):
self._source_dir = str(evolve_dir)
self.project_path = str(evolve_dir)
self._ensure_origin_reachable()
return True
logger.warning(
"EvolutionHandler[%s]: %s exists but is not a git repo — "
Expand All @@ -361,15 +369,7 @@ def _ensure_evolution_workspace(self) -> bool:
"EvolutionHandler[%s]: cloning %s → %s (workspace self-heal)",
self.name, repo_url, evolve_dir,
)
subprocess.run(
["git", "clone", repo_url, str(evolve_dir)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
check=True,
env=no_prompt_env(),
)
self._clone_workspace(repo_url, evolve_dir)
self._align_to_installed_version(evolve_dir)
self._ensure_git_identity(evolve_dir)
self._source_dir = str(evolve_dir)
Expand All @@ -384,6 +384,88 @@ def _ensure_evolution_workspace(self) -> bool:
)
return False

def _clone_workspace(self, repo_url: str, target: Path) -> None:
"""Clone the evolution repo, retrying via SSH when https is blocked.

Uses a short ``http.connectTimeout`` so a blocked github.com:443
fails fast (seconds) instead of hanging; on a connection-type
failure the clone is retried with the SSH URL
(``git@github.com:owner/repo.git``), which works on networks that
block https git transport (observed on the packaged host). Other
failures (auth / 404 / repo-specific) propagate unchanged.
"""
cmd = ["git", "-c", "http.connectTimeout=10", "clone", repo_url, str(target)]
reason = ""
try:
subprocess.run(
cmd, capture_output=True, text=True, encoding="utf-8",
timeout=120, check=True, env=no_prompt_env(),
)
return
except subprocess.CalledProcessError as e:
ssh_url = https_to_ssh_url(repo_url)
if not ssh_url or not is_git_connection_error(e.stderr or ""):
raise
# 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",
self.name, reason,
)
subprocess.run(
["git", "clone", ssh_url, str(target)],
capture_output=True, text=True, encoding="utf-8",
timeout=120, check=True, env=no_prompt_env(),
)

def _ensure_origin_reachable(self) -> None:
"""Probe the github.com https origin; switch to SSH when blocked.

Some networks block github.com:443 while SSH port 22 stays open.
With an https origin every evolution pull/push hangs ~75 s and the
saturation-halt auto-resume (``git ls-remote``) never fires,
silently starving the cycle. One cheap probe per handler lifetime
(bounded by ``http.connectTimeout``) detects the blocked case; on
success nothing changes; on a connection-type failure the origin is
switched to the equivalent SSH URL so pull/push/ls-remote keep
working. Auth/404 errors never trigger a switch.
"""
if self._origin_probed:
return
self._origin_probed = True
origin = git_origin_url(self._source_dir)
ssh_url = https_to_ssh_url(origin)
if not ssh_url:
return # not a github.com https origin — nothing to switch
result = subprocess.run(
["git", "-c", "http.connectTimeout=4", "ls-remote", origin, "HEAD"],
cwd=self._source_dir,
capture_output=True,
text=True,
encoding="utf-8",
timeout=15,
env=no_prompt_env(),
)
if result.returncode == 0:
return # reachable — keep https
if not is_git_connection_error(result.stderr):
return # auth/404 etc — switching would not help
switch = subprocess.run(
["git", "remote", "set-url", "origin", ssh_url],
cwd=self._source_dir,
capture_output=True,
text=True,
encoding="utf-8",
timeout=5,
env=no_prompt_env(),
)
if switch.returncode == 0:
logger.warning(
"EvolutionHandler[%s]: https origin unreachable (%s) — "
"switched origin to %s",
self.name, (result.stderr.strip() or "")[:80], ssh_url,
)

def _load_saturation_state(self) -> int:
"""Restore _empty_cycles counter from disk (survives daemon restarts)."""
try:
Expand Down Expand Up @@ -524,13 +606,26 @@ def _remote_advanced(self) -> bool:
if not local:
return False
result = subprocess.run(
["git", "ls-remote", "origin", "master"],
["git", "-c", "http.connectTimeout=4", "ls-remote", "origin", "master"],
cwd=self._source_dir,
capture_output=True,
text=True,
timeout=10,
timeout=15,
env=no_prompt_env(),
)
if result.returncode != 0:
# https github.com may be blocked while SSH port 22 works —
# retry with the SSH form of the origin before giving up.
ssh_url = https_to_ssh_url(git_origin_url(self._source_dir))
if ssh_url and is_git_connection_error(result.stderr):
result = subprocess.run(
["git", "ls-remote", ssh_url, "master"],
cwd=self._source_dir,
capture_output=True,
text=True,
timeout=15,
env=no_prompt_env(),
)
if result.returncode != 0:
return False
remote = result.stdout.strip().split()
Expand Down
73 changes: 73 additions & 0 deletions tests/test_git_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,76 @@ def test_parse_gh_auth_user_empty():

assert parse_gh_auth_user("") is None
assert parse_gh_auth_user(None) is None # type: ignore[arg-type]


# ── HTTPS→SSH fallback helpers (2026-08-08) ───────────────────────

def test_https_to_ssh_url_dot_git():
from emrg.server.git_utils import https_to_ssh_url

assert https_to_ssh_url("https://github.com/argszero/emrg.git") == "git@github.com:argszero/emrg.git"


def test_https_to_ssh_url_no_dot_git():
from emrg.server.git_utils import https_to_ssh_url

assert https_to_ssh_url("https://github.com/argszero/emrg") == "git@github.com:argszero/emrg.git"


def test_https_to_ssh_url_rejects_ssh_url():
from emrg.server.git_utils import https_to_ssh_url

assert https_to_ssh_url("git@github.com:argszero/emrg.git") is None


def test_https_to_ssh_url_rejects_other_hosts_and_garbage():
from emrg.server.git_utils import https_to_ssh_url

assert https_to_ssh_url("https://gitlab.com/argszero/emrg.git") is None
assert https_to_ssh_url("https://github.example.com/a/b.git") is None
assert https_to_ssh_url("") is None
assert https_to_ssh_url(None) is None # type: ignore[arg-type]
assert https_to_ssh_url("file:///tmp/repo") is None


def test_is_git_connection_error_matches_connection_failures():
from emrg.server.git_utils import is_git_connection_error

assert is_git_connection_error(
"fatal: unable to access 'https://github.com/a/b.git/': "
"Failed to connect to github.com port 443 after 10013 ms"
)
assert is_git_connection_error("ssh: connect to host github.com port 22: Connection refused")


def test_is_git_connection_error_rejects_auth_and_404():
from emrg.server.git_utils import is_git_connection_error

assert not is_git_connection_error("remote: Repository not found.")
assert not is_git_connection_error("Permission denied (publickey).")
assert not is_git_connection_error("Authentication failed for 'https://github.com/a/b.git'")
assert not is_git_connection_error("")
assert not is_git_connection_error(None) # type: ignore[arg-type]


def test_git_origin_url_real_repo():
"""Reads the raw origin URL from a real repo."""
import subprocess as real_subprocess

from emrg.server.git_utils import git_origin_url

repo = real_subprocess.run(["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True)
if repo.returncode != 0:
return # not in a git repo (packaged source) — skip
url = git_origin_url(repo.stdout.strip())
assert isinstance(url, str)
assert url # the evolution workspace has an origin


def test_git_origin_url_missing_remote(tmp_path):
import subprocess as real_subprocess

from emrg.server.git_utils import git_origin_url

real_subprocess.run(["git", "init", "-q", str(tmp_path)], check=True)
assert git_origin_url(str(tmp_path)) == ""
Loading
Loading