✨ Add new files
This commit is contained in:
parent
24db364b44
commit
2d0ae9d5e4
19 changed files with 235 additions and 20 deletions
44
README.md
44
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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue