feat(@ml/auto-commit-service): ✨ add stalled repos indicator
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
90f06a77aa
commit
d70b90a60b
2 changed files with 108 additions and 8 deletions
|
|
@ -35,6 +35,7 @@ ICON_OK = "✓"
|
|||
ICON_DEGRADED = "⚠"
|
||||
ICON_ERROR = "✗"
|
||||
ICON_OFFLINE = "○"
|
||||
ICON_STALLED = "!"
|
||||
|
||||
POLL_INTERVAL = 30 # seconds
|
||||
DEFAULT_CYCLE_SECONDS = 300 # 5 minutes
|
||||
|
|
@ -88,6 +89,9 @@ class CommitsTrayApp(rumps.App):
|
|||
self._commits_menu = rumps.MenuItem("Recent Commits")
|
||||
self._commits_menu.add(rumps.MenuItem("Loading...", callback=None))
|
||||
|
||||
self._stalled_menu = rumps.MenuItem("Stalled Repos")
|
||||
self._stalled_menu.add(rumps.MenuItem("None", callback=None))
|
||||
|
||||
self._quit_item = rumps.MenuItem("Quit", callback=self._on_quit)
|
||||
|
||||
self.menu = [
|
||||
|
|
@ -98,6 +102,7 @@ class CommitsTrayApp(rumps.App):
|
|||
self._toggle_item,
|
||||
self._trigger_item,
|
||||
None,
|
||||
self._stalled_menu,
|
||||
self._commits_menu,
|
||||
None,
|
||||
self._quit_item,
|
||||
|
|
@ -123,25 +128,33 @@ class CommitsTrayApp(rumps.App):
|
|||
agent_running = self.agent.is_running
|
||||
agent_enabled = self.agent.is_enabled
|
||||
|
||||
# Stalled repos (from last cycle). Drives icon + submenu.
|
||||
last = self.agent.last_cycle
|
||||
stalled = list(last.stalled_repos) if last else []
|
||||
|
||||
# Icon
|
||||
if agent_running and llm_up:
|
||||
icon = ICON_OK
|
||||
elif agent_running and not llm_up:
|
||||
if not agent_running:
|
||||
icon = ICON_ERROR
|
||||
elif stalled:
|
||||
icon = ICON_STALLED
|
||||
elif not llm_up:
|
||||
icon = ICON_DEGRADED
|
||||
else:
|
||||
icon = ICON_ERROR
|
||||
icon = ICON_OK
|
||||
|
||||
self.title = f"{icon} ACS"
|
||||
title_suffix = f" ({len(stalled)}!)" if stalled else ""
|
||||
self.title = f"{icon} ACS{title_suffix}"
|
||||
|
||||
# Status lines
|
||||
llm_label = "up" if llm_up else "down"
|
||||
self._status_item.title = f"LLM: {llm_label} ({self._daemon_url})"
|
||||
|
||||
if agent_running and agent_enabled:
|
||||
last = self.agent.last_cycle
|
||||
if last:
|
||||
stalled_suffix = f", {len(stalled)} stalled" if stalled else ""
|
||||
self._agent_status_item.title = (
|
||||
f"Agent: running | last: {last.repos_committed} committed"
|
||||
f"{stalled_suffix}"
|
||||
)
|
||||
else:
|
||||
self._agent_status_item.title = "Agent: running | no cycles yet"
|
||||
|
|
@ -150,6 +163,21 @@ class CommitsTrayApp(rumps.App):
|
|||
else:
|
||||
self._agent_status_item.title = "Agent: stopped"
|
||||
|
||||
# Refresh stalled submenu
|
||||
self._stalled_menu.clear()
|
||||
if stalled:
|
||||
for entry in stalled:
|
||||
name = entry.get("repo_name", "?")
|
||||
ahead = entry.get("ahead", 0)
|
||||
behind = entry.get("behind", 0)
|
||||
reason = entry.get("reason", "")
|
||||
label = f"{name} {ahead}↑ {behind}↓"
|
||||
if reason == "claude_partial":
|
||||
label += " (claude partial)"
|
||||
self._stalled_menu.add(rumps.MenuItem(label, callback=None))
|
||||
else:
|
||||
self._stalled_menu.add(rumps.MenuItem("None", callback=None))
|
||||
|
||||
# Repos count
|
||||
repo_count = len(self.agent._repos)
|
||||
self._repos_item.title = f"Repos: {repo_count} local"
|
||||
|
|
|
|||
|
|
@ -366,13 +366,13 @@ class LocalCommitAgent:
|
|||
self._client.close()
|
||||
|
||||
|
||||
def _git(repo_path: Path, *args: str, max_bytes: int = 0) -> str:
|
||||
def _git(repo_path: Path, *args: str, max_bytes: int = 0, timeout: int = 30) -> str:
|
||||
"""Run a git command and return stdout."""
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(repo_path), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
timeout=timeout,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"git {args[0]} failed: {result.stderr.strip()}")
|
||||
|
|
@ -382,6 +382,78 @@ def _git(repo_path: Path, *args: str, max_bytes: int = 0) -> str:
|
|||
return output
|
||||
|
||||
|
||||
def _upstream_ref(repo_path: Path) -> str | None:
|
||||
"""Return the tracking branch ref (e.g. 'origin/main'), or None if unset."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(repo_path),
|
||||
"rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
ref = result.stdout.strip()
|
||||
return ref or None
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _ahead_behind(repo_path: Path, upstream: str) -> tuple[int, int]:
|
||||
"""Return (ahead, behind) for HEAD vs upstream. (0, 0) on failure."""
|
||||
try:
|
||||
out = _git(
|
||||
repo_path, "rev-list", "--count", "--left-right",
|
||||
f"{upstream}...HEAD", timeout=10,
|
||||
).strip()
|
||||
behind_str, ahead_str = out.split()
|
||||
return int(ahead_str), int(behind_str)
|
||||
except Exception:
|
||||
return 0, 0
|
||||
|
||||
|
||||
def _invoke_claude_recovery(
|
||||
repo_path: Path,
|
||||
repo_name: str,
|
||||
upstream: str,
|
||||
ahead: int,
|
||||
behind: int,
|
||||
timeout: int = 300,
|
||||
) -> bool:
|
||||
"""Shell out to `claude` to rebase a diverged branch. Returns True on exit 0."""
|
||||
prompt = CLAUDE_RECOVERY_PROMPT.format(
|
||||
repo_name=repo_name,
|
||||
repo_path=repo_path,
|
||||
upstream=upstream,
|
||||
ahead=ahead,
|
||||
behind=behind,
|
||||
)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["claude", "--dangerously-skip-permissions", "--print"],
|
||||
input=prompt,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(repo_path),
|
||||
timeout=timeout,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
return True
|
||||
logger.error(
|
||||
f"claude recovery non-zero for {repo_name}: "
|
||||
f"{proc.stderr.strip()[:300] or proc.stdout.strip()[:300]}"
|
||||
)
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(f"claude recovery timed out for {repo_name} after {timeout}s")
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
logger.error("claude CLI not found in PATH — cannot recover diverged repos")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.exception(f"claude recovery unexpected error for {repo_name}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _repo_display_name(repo_path: Path) -> str:
|
||||
"""Short display name for a repo path."""
|
||||
home = Path.home()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue