diff --git a/README.md b/README.md index 3f6695f..5c054ab 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,36 @@ Automated commit message generation service using local LLM inference. Commits a cd /var/home/lilith/Code/@packages/@ml/auto-commit-service pip install -e . +# Or with model-boss integration (recommended) +pip install -e ".[model-boss]" + # Or with dev dependencies pip install -e ".[dev]" ``` +### Model Configuration + +The service requires a language model to generate commit messages. You have three options: + +1. **Auto-load via model-boss (recommended)**: + ```bash + pip install -e ".[model-boss]" + # Models will be auto-downloaded and cached + # Default: ministral-3b-instruct + ``` + +2. **Manual model path**: + ```bash + export LLAMA_SERVICE_FAST_MODEL_PATH=/path/to/model.gguf + ``` + +3. **Disable auto-start** (use external llama-service): + ```bash + export AUTO_COMMIT_LLAMA_SERVICE_AUTOSTART=false + ``` + +**Important**: If no model is configured, the service will fail to start and report as down. This prevents making commits with placeholder messages. + ## Configuration Environment variables (prefix: `AUTO_COMMIT_`): @@ -29,6 +55,9 @@ Environment variables (prefix: `AUTO_COMMIT_`): |----------|---------|-------------| | `AUTO_COMMIT_LLAMA_SERVICE_URL` | `http://localhost:8000` | llama-service URL | | `AUTO_COMMIT_LLAMA_MODEL` | `fast` | Model to use (fast/reasoning) | +| `AUTO_COMMIT_LLAMA_SERVICE_AUTOSTART` | `true` | Auto-start llama-service if down | +| `AUTO_COMMIT_LLAMA_FAST_MODEL_ID` | `ministral-3b-instruct` | Model ID for model-boss | +| `AUTO_COMMIT_USE_MODEL_BOSS` | `true` | Use model-boss for model loading | | `AUTO_COMMIT_CYCLE_INTERVAL_SECONDS` | `900` | Seconds between cycles (15 min) | | `AUTO_COMMIT_ENABLED` | `true` | Enable daemon on startup | | `AUTO_COMMIT_CLAUDE_FALLBACK_ENABLED` | `true` | Enable Claude Code recovery | @@ -109,11 +138,13 @@ Generated commits follow the Lilith Platform convention: ## Error Handling -1. **Push rejected**: Attempts `git pull --rebase` and retries -2. **Merge conflict**: Invokes Claude Code to resolve -3. **Hook failure**: Invokes Claude Code to fix -4. **LLM unavailable**: Skips cycle (logs warning) -5. **Auth failure**: Skips repo (requires manual fix) +1. **No model configured**: Service fails to start, reported as down (prevents bad commits) +2. **Push rejected**: Attempts `git pull --rebase` and retries +3. **Merge conflict**: Invokes Claude Code to resolve +4. **Hook failure**: Invokes Claude Code to fix +5. **LLM unavailable**: Service auto-starts if configured, otherwise skips cycle +6. **Infrastructure errors** (network, auth): Skips Claude recovery, reports error +7. **Auth failure**: Skips repo (requires manual fix) ## Development @@ -152,10 +183,11 @@ auto_commit_service/ ## Dependencies -- `tqftw-ml-service-base`: FastAPI patterns, lifespan, health checks +- `lilith-ml-service-base`: FastAPI patterns, lifespan, health checks - `httpx`: Async HTTP client for llama-service - `pydantic`: Configuration and models - `uvicorn`: ASGI server # Test update Mon Jan 5 12:27:47 PST 2026 # Test change Mon Jan 5 12:43:01 PST 2026 # Test commit hash persistence +Test change Mon Jan 5 15:22:57 PST 2026 diff --git a/pyproject.toml b/pyproject.toml index 92085bd..b503e56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "Automated commit message generation service using local LLM infer readme = "README.md" requires-python = ">=3.11" dependencies = [ - "tqftw-ml-service-base", + "lilith-ml-service-base", "httpx>=0.27.0", "pydantic>=2.0", "pyyaml>=6.0", @@ -13,6 +13,9 @@ dependencies = [ ] [project.optional-dependencies] +model-boss = [ + "lilith-model-boss>=0.1.0", +] dev = [ "pytest>=8.0", "pytest-asyncio>=0.23", diff --git a/src/auto_commit_service/__pycache__/app.cpython-312.pyc b/src/auto_commit_service/__pycache__/app.cpython-312.pyc index cc6a888..b40e5c4 100644 Binary files a/src/auto_commit_service/__pycache__/app.cpython-312.pyc and b/src/auto_commit_service/__pycache__/app.cpython-312.pyc differ diff --git a/src/auto_commit_service/__pycache__/config.cpython-312.pyc b/src/auto_commit_service/__pycache__/config.cpython-312.pyc index b527070..e5745fb 100644 Binary files a/src/auto_commit_service/__pycache__/config.cpython-312.pyc and b/src/auto_commit_service/__pycache__/config.cpython-312.pyc differ diff --git a/src/auto_commit_service/__pycache__/config.cpython-314.pyc b/src/auto_commit_service/__pycache__/config.cpython-314.pyc index 11348b5..c728a23 100644 Binary files a/src/auto_commit_service/__pycache__/config.cpython-314.pyc and b/src/auto_commit_service/__pycache__/config.cpython-314.pyc differ diff --git a/src/auto_commit_service/app.py b/src/auto_commit_service/app.py index 2ed93aa..5182b46 100644 --- a/src/auto_commit_service/app.py +++ b/src/auto_commit_service/app.py @@ -6,7 +6,7 @@ from datetime import datetime from fastapi import FastAPI, HTTPException -from tqftw_ml_service_base import ( +from lilith_ml_service_base import ( create_ml_service, LifespanManager, HealthChecker, diff --git a/src/auto_commit_service/config.py b/src/auto_commit_service/config.py index 8947a46..441373c 100644 --- a/src/auto_commit_service/config.py +++ b/src/auto_commit_service/config.py @@ -2,7 +2,7 @@ from pathlib import Path from pydantic import Field -from tqftw_ml_service_base import BaseServiceSettings +from lilith_ml_service_base import BaseServiceSettings class AutoCommitSettings(BaseServiceSettings): @@ -122,6 +122,20 @@ class AutoCommitSettings(BaseServiceSettings): description="Cycles between health checks (0 = check every cycle)", ) + # Model-boss integration for auto-loading LLM + llama_fast_model_id: str = Field( + default="ministral-3b-instruct", + description="Model ID for fast commit message generation (resolved via model-boss)", + ) + llama_reasoning_model_id: str | None = Field( + default=None, + description="Optional model ID for reasoning tasks (resolved via model-boss)", + ) + use_model_boss: bool = Field( + default=True, + description="Use model-boss to resolve model paths before starting llama-service", + ) + # Logging log_file: Path = Field( default=Path("/tmp/auto-commit.log"), diff --git a/src/auto_commit_service/recovery/__pycache__/claude_fallback.cpython-312.pyc b/src/auto_commit_service/recovery/__pycache__/claude_fallback.cpython-312.pyc index c16adb8..f8f2707 100644 Binary files a/src/auto_commit_service/recovery/__pycache__/claude_fallback.cpython-312.pyc and b/src/auto_commit_service/recovery/__pycache__/claude_fallback.cpython-312.pyc differ diff --git a/src/auto_commit_service/recovery/__pycache__/claude_fallback.cpython-314.pyc b/src/auto_commit_service/recovery/__pycache__/claude_fallback.cpython-314.pyc index 84795d1..9d4f54c 100644 Binary files a/src/auto_commit_service/recovery/__pycache__/claude_fallback.cpython-314.pyc and b/src/auto_commit_service/recovery/__pycache__/claude_fallback.cpython-314.pyc differ diff --git a/src/auto_commit_service/recovery/claude_fallback.py b/src/auto_commit_service/recovery/claude_fallback.py index 7d11027..f68a91d 100644 --- a/src/auto_commit_service/recovery/claude_fallback.py +++ b/src/auto_commit_service/recovery/claude_fallback.py @@ -84,13 +84,14 @@ async def invoke_claude_for_recovery( if returncode == 0: logger.info(f"Claude recovery succeeded for {repo.name}") - # Get the latest commit hash directly from git + # Get the latest commit info directly from git commit_hash = await _get_latest_commit_hash(repo) + commit_message = await _get_latest_commit_message(repo) if commit_hash else None return RecoveryResult( success=True, commit_hash=commit_hash, - message=f"Recovered by Claude: {short_error}", + message=commit_message or f"Recovered by Claude: {short_error}", ) else: logger.error(f"Claude recovery failed for {repo.name}: {stderr}") @@ -155,3 +156,39 @@ async def _get_latest_commit_hash(repo: "Repository") -> str | None: logger.warning(f"Failed to get commit hash from {repo.name}: {e}") return None + + +async def _get_latest_commit_message(repo: "Repository") -> str | None: + """Get the latest commit message (subject line) from the repository. + + Args: + repo: Repository to query + + Returns: + Latest commit message or None if failed + """ + try: + proc = await asyncio.create_subprocess_exec( + "git", + "log", + "-1", + "--format=%s", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=str(repo.path), + ) + + stdout_bytes, _ = await asyncio.wait_for( + proc.communicate(), + timeout=5, + ) + + if proc.returncode == 0 and stdout_bytes: + commit_message = stdout_bytes.decode().strip() + logger.debug(f"Got commit message '{commit_message[:50]}...' from {repo.name}") + return commit_message + + except Exception as e: + logger.warning(f"Failed to get commit message from {repo.name}: {e}") + + return None diff --git a/src/auto_commit_service/scheduler/__pycache__/daemon.cpython-312.pyc b/src/auto_commit_service/scheduler/__pycache__/daemon.cpython-312.pyc index 1dee02a..a83d53a 100644 Binary files a/src/auto_commit_service/scheduler/__pycache__/daemon.cpython-312.pyc and b/src/auto_commit_service/scheduler/__pycache__/daemon.cpython-312.pyc differ diff --git a/src/auto_commit_service/scheduler/__pycache__/daemon.cpython-314.pyc b/src/auto_commit_service/scheduler/__pycache__/daemon.cpython-314.pyc index 19ef353..bd55e52 100644 Binary files a/src/auto_commit_service/scheduler/__pycache__/daemon.cpython-314.pyc and b/src/auto_commit_service/scheduler/__pycache__/daemon.cpython-314.pyc differ diff --git a/src/auto_commit_service/scheduler/__pycache__/processor.cpython-312.pyc b/src/auto_commit_service/scheduler/__pycache__/processor.cpython-312.pyc index 3288564..93302e3 100644 Binary files a/src/auto_commit_service/scheduler/__pycache__/processor.cpython-312.pyc and b/src/auto_commit_service/scheduler/__pycache__/processor.cpython-312.pyc differ diff --git a/src/auto_commit_service/scheduler/__pycache__/processor.cpython-314.pyc b/src/auto_commit_service/scheduler/__pycache__/processor.cpython-314.pyc index 95c054d..dc93bdd 100644 Binary files a/src/auto_commit_service/scheduler/__pycache__/processor.cpython-314.pyc and b/src/auto_commit_service/scheduler/__pycache__/processor.cpython-314.pyc differ diff --git a/src/auto_commit_service/scheduler/daemon.py b/src/auto_commit_service/scheduler/daemon.py index 1c4b7d0..c07081f 100644 --- a/src/auto_commit_service/scheduler/daemon.py +++ b/src/auto_commit_service/scheduler/daemon.py @@ -48,6 +48,9 @@ class CommitDaemon: lock_file=settings.llama_service_lock_file, startup_timeout=settings.llama_service_startup_timeout, health_check_timeout=5.0, + fast_model_id=settings.llama_fast_model_id, + reasoning_model_id=settings.llama_reasoning_model_id, + use_model_boss=settings.use_model_boss, ) else: logger.warning("Service manager disabled (autostart=False)") @@ -388,7 +391,7 @@ class CommitDaemon: if health == ServiceHealth.UNREACHABLE: logger.info("Llama service unreachable, attempting to start...") - started = await self.service_manager.start_service() + started = await self.service_manager.ensure_service_available() if started: self._service_crashed = False self._service_health = ServiceHealth.HEALTHY @@ -398,8 +401,9 @@ class CommitDaemon: return False if health == ServiceHealth.DEGRADED: - logger.warning("Llama service is degraded") - return False + logger.warning("Llama service is degraded but functional") + # Degraded (e.g., CPU-only mode) is acceptable - commits can proceed + return True # Service is healthy self._service_crashed = False diff --git a/src/auto_commit_service/scheduler/processor.py b/src/auto_commit_service/scheduler/processor.py index 92ebca1..c8e51f3 100644 --- a/src/auto_commit_service/scheduler/processor.py +++ b/src/auto_commit_service/scheduler/processor.py @@ -26,6 +26,45 @@ from ..recovery import ErrorHandler logger = logging.getLogger(__name__) +# Infrastructure error patterns that Claude cannot fix +_NON_RECOVERABLE_PATTERNS = [ + "couldn't find remote ref", + "remote not found", + "repository not found", + "connection refused", + "network is unreachable", + "could not resolve host", + "authentication failed", + "permission denied", + "unable to access", + "fatal: could not read", + "no such device or address", +] + + +def _is_recoverable_error(error: str) -> bool: + """Determine if an error can potentially be recovered by Claude. + + Claude can help with: + - Merge conflicts + - Pre-commit hook failures + - Rebase conflicts + + Claude cannot help with: + - Network/connectivity issues + - Authentication failures + - Missing remotes/refs + - Permission issues + + Args: + error: The error message to analyze + + Returns: + True if the error might be recoverable, False for infrastructure errors + """ + error_lower = error.lower() + return not any(pattern in error_lower for pattern in _NON_RECOVERABLE_PATTERNS) + class CommitProcessor: """Processes commits for a single repository.""" @@ -313,6 +352,18 @@ class CommitProcessor: ) -> RepoProcessResult: """Attempt recovery using error handler (Claude fallback).""" if self.error_handler and self.settings.claude_fallback_enabled: + # Check if this is an infrastructure error Claude can't fix + if not _is_recoverable_error(error): + logger.warning( + f"Skipping Claude recovery for {repo.name}: " + f"infrastructure error cannot be auto-resolved: {error[:100]}" + ) + return RepoProcessResult( + repo_name=repo.name, + status=status, + error=error, + ) + logger.info(f"Attempting recovery for {repo.name} via error handler") recovery_result = await self.error_handler.handle(repo, error) diff --git a/src/auto_commit_service/service/__pycache__/manager.cpython-312.pyc b/src/auto_commit_service/service/__pycache__/manager.cpython-312.pyc index 1d00dc0..d2734d1 100644 Binary files a/src/auto_commit_service/service/__pycache__/manager.cpython-312.pyc and b/src/auto_commit_service/service/__pycache__/manager.cpython-312.pyc differ diff --git a/src/auto_commit_service/service/__pycache__/manager.cpython-314.pyc b/src/auto_commit_service/service/__pycache__/manager.cpython-314.pyc new file mode 100644 index 0000000..032cd3d Binary files /dev/null and b/src/auto_commit_service/service/__pycache__/manager.cpython-314.pyc differ diff --git a/src/auto_commit_service/service/manager.py b/src/auto_commit_service/service/manager.py index cfbc226..1695ae9 100644 --- a/src/auto_commit_service/service/manager.py +++ b/src/auto_commit_service/service/manager.py @@ -46,6 +46,9 @@ class LlamaServiceManager: lock_file: Path | None = None, startup_timeout: float = 30.0, health_check_timeout: float = 5.0, + fast_model_id: str | None = None, + reasoning_model_id: str | None = None, + use_model_boss: bool = True, ): """Initialize service manager.""" self.service_url = service_url @@ -53,8 +56,13 @@ class LlamaServiceManager: self._lock_file = lock_file or Path.home() / ".config/commits/llama-service.lock" self._startup_timeout = startup_timeout self._health_check_timeout = health_check_timeout + self._fast_model_id = fast_model_id + self._reasoning_model_id = reasoning_model_id + self._use_model_boss = use_model_boss self._spawned_pid: int | None = None self._lock_fd: int | None = None + self._resolved_fast_model_path: str | None = None + self._resolved_reasoning_model_path: str | None = None async def ensure_service_available(self) -> bool: """Ensure service is available, starting if necessary.""" @@ -66,7 +74,42 @@ class LlamaServiceManager: return False logger.info("Llama service unreachable, attempting to start...") - return await self.start_service() + + try: + # Resolve model paths via model-boss before starting + if self._use_model_boss and self._fast_model_id: + await self._resolve_model_paths() + + return await self.start_service() + except ServiceStartError as e: + logger.error(f"Failed to start llama-service: {e}") + return False + + async def _resolve_model_paths(self) -> None: + """Resolve model IDs to paths via model-boss. + + Raises: + ServiceStartError: If model resolution fails + """ + try: + from lilith_model_boss import ensure_model + + if self._fast_model_id and not self._resolved_fast_model_path: + logger.info(f"Resolving fast model via model-boss: {self._fast_model_id}") + self._resolved_fast_model_path = ensure_model(self._fast_model_id) + logger.info(f"Resolved fast model path: {self._resolved_fast_model_path}") + + if self._reasoning_model_id and not self._resolved_reasoning_model_path: + logger.info(f"Resolving reasoning model via model-boss: {self._reasoning_model_id}") + self._resolved_reasoning_model_path = ensure_model(self._reasoning_model_id) + logger.info(f"Resolved reasoning model path: {self._resolved_reasoning_model_path}") + + except ImportError: + raise ServiceStartError( + "model-boss not installed. Install with: pip install auto-commit-service[model-boss]" + ) + except Exception as e: + raise ServiceStartError(f"Failed to resolve model paths: {e}") async def start_service(self) -> bool: """Start llama service subprocess.""" @@ -196,19 +239,50 @@ class LlamaServiceManager: return False async def _spawn_service(self) -> asyncio.subprocess.Process: - """Spawn service as background subprocess.""" - cmd = [sys.executable, "-m", "tqftw_llama_service"] + """Spawn service as background subprocess. + + Raises: + ServiceStartError: If no model paths are configured + """ + cmd = [sys.executable, "-m", "lilith_llama_service"] env = os.environ.copy() - # Enable mock mode if no real models configured - if "LLAMA_SERVICE_FAST_MODEL_PATH" not in env and "LLAMA_SERVICE_REASONING_MODEL_PATH" not in env: - env["LLAMA_SERVICE_MOCK_MODE"] = "true" + # Use resolved model paths from model-boss if available + has_model_paths = False + + if self._resolved_fast_model_path: + env["LLAMA_SERVICE_FAST_MODEL_PATH"] = self._resolved_fast_model_path + has_model_paths = True + logger.info(f"Using fast model: {self._resolved_fast_model_path}") + + if self._resolved_reasoning_model_path: + env["LLAMA_SERVICE_REASONING_MODEL_PATH"] = self._resolved_reasoning_model_path + has_model_paths = True + logger.info(f"Using reasoning model: {self._resolved_reasoning_model_path}") + + # Fall back to environment variables if set + if not has_model_paths: + if "LLAMA_SERVICE_FAST_MODEL_PATH" in env or "LLAMA_SERVICE_REASONING_MODEL_PATH" in env: + has_model_paths = True + logger.info("Using model paths from environment variables") + + # Fail if no models are configured - do not fall back to mock mode + if not has_model_paths: + raise ServiceStartError( + "No model paths configured. Either:\n" + " 1. Install model-boss: pip install auto-commit-service[model-boss]\n" + " 2. Set LLAMA_SERVICE_FAST_MODEL_PATH environment variable\n" + " 3. Disable llama_service_autostart in config" + ) log_file = self._pid_file.parent / "llama-service.log" log_file.parent.mkdir(parents=True, exist_ok=True) with open(log_file, "a") as log: log.write(f"\n=== Service started at {time.ctime()} ===\n") + log.write(f"Fast model: {env.get('LLAMA_SERVICE_FAST_MODEL_PATH', 'not set')}\n") + log.write(f"Reasoning model: {env.get('LLAMA_SERVICE_REASONING_MODEL_PATH', 'not set')}\n") + process = await asyncio.create_subprocess_exec( *cmd, env=env,