feat(@ml/auto-commit-service): add stalled repos indicator

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-14 17:56:00 -07:00
parent 90f06a77aa
commit d70b90a60b
2 changed files with 108 additions and 8 deletions

View file

@ -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"

View file

@ -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()