diff --git a/src/auto_commit_service/tray/app.py b/src/auto_commit_service/tray/app.py index 9c770ac..6bbcc49 100644 --- a/src/auto_commit_service/tray/app.py +++ b/src/auto_commit_service/tray/app.py @@ -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" diff --git a/src/auto_commit_service/tray/local_agent.py b/src/auto_commit_service/tray/local_agent.py index 7afcad5..2fbca69 100644 --- a/src/auto_commit_service/tray/local_agent.py +++ b/src/auto_commit_service/tray/local_agent.py @@ -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()