Add new files

This commit is contained in:
Lilith 2026-01-05 15:24:28 -08:00
parent 24db364b44
commit 2d0ae9d5e4
19 changed files with 235 additions and 20 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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