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 cd /var/home/lilith/Code/@packages/@ml/auto-commit-service
pip install -e . pip install -e .
# Or with model-boss integration (recommended)
pip install -e ".[model-boss]"
# Or with dev dependencies # Or with dev dependencies
pip install -e ".[dev]" 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 ## Configuration
Environment variables (prefix: `AUTO_COMMIT_`): 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_SERVICE_URL` | `http://localhost:8000` | llama-service URL |
| `AUTO_COMMIT_LLAMA_MODEL` | `fast` | Model to use (fast/reasoning) | | `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_CYCLE_INTERVAL_SECONDS` | `900` | Seconds between cycles (15 min) |
| `AUTO_COMMIT_ENABLED` | `true` | Enable daemon on startup | | `AUTO_COMMIT_ENABLED` | `true` | Enable daemon on startup |
| `AUTO_COMMIT_CLAUDE_FALLBACK_ENABLED` | `true` | Enable Claude Code recovery | | `AUTO_COMMIT_CLAUDE_FALLBACK_ENABLED` | `true` | Enable Claude Code recovery |
@ -109,11 +138,13 @@ Generated commits follow the Lilith Platform convention:
## Error Handling ## Error Handling
1. **Push rejected**: Attempts `git pull --rebase` and retries 1. **No model configured**: Service fails to start, reported as down (prevents bad commits)
2. **Merge conflict**: Invokes Claude Code to resolve 2. **Push rejected**: Attempts `git pull --rebase` and retries
3. **Hook failure**: Invokes Claude Code to fix 3. **Merge conflict**: Invokes Claude Code to resolve
4. **LLM unavailable**: Skips cycle (logs warning) 4. **Hook failure**: Invokes Claude Code to fix
5. **Auth failure**: Skips repo (requires manual 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 ## Development
@ -152,10 +183,11 @@ auto_commit_service/
## Dependencies ## 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 - `httpx`: Async HTTP client for llama-service
- `pydantic`: Configuration and models - `pydantic`: Configuration and models
- `uvicorn`: ASGI server - `uvicorn`: ASGI server
# Test update Mon Jan 5 12:27:47 PST 2026 # Test update Mon Jan 5 12:27:47 PST 2026
# Test change Mon Jan 5 12:43:01 PST 2026 # Test change Mon Jan 5 12:43:01 PST 2026
# Test commit hash persistence # 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" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"tqftw-ml-service-base", "lilith-ml-service-base",
"httpx>=0.27.0", "httpx>=0.27.0",
"pydantic>=2.0", "pydantic>=2.0",
"pyyaml>=6.0", "pyyaml>=6.0",
@ -13,6 +13,9 @@ dependencies = [
] ]
[project.optional-dependencies] [project.optional-dependencies]
model-boss = [
"lilith-model-boss>=0.1.0",
]
dev = [ dev = [
"pytest>=8.0", "pytest>=8.0",
"pytest-asyncio>=0.23", "pytest-asyncio>=0.23",

View file

@ -6,7 +6,7 @@ from datetime import datetime
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from tqftw_ml_service_base import ( from lilith_ml_service_base import (
create_ml_service, create_ml_service,
LifespanManager, LifespanManager,
HealthChecker, HealthChecker,

View file

@ -2,7 +2,7 @@
from pathlib import Path from pathlib import Path
from pydantic import Field from pydantic import Field
from tqftw_ml_service_base import BaseServiceSettings from lilith_ml_service_base import BaseServiceSettings
class AutoCommitSettings(BaseServiceSettings): class AutoCommitSettings(BaseServiceSettings):
@ -122,6 +122,20 @@ class AutoCommitSettings(BaseServiceSettings):
description="Cycles between health checks (0 = check every cycle)", 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 # Logging
log_file: Path = Field( log_file: Path = Field(
default=Path("/tmp/auto-commit.log"), default=Path("/tmp/auto-commit.log"),

View file

@ -84,13 +84,14 @@ async def invoke_claude_for_recovery(
if returncode == 0: if returncode == 0:
logger.info(f"Claude recovery succeeded for {repo.name}") 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_hash = await _get_latest_commit_hash(repo)
commit_message = await _get_latest_commit_message(repo) if commit_hash else None
return RecoveryResult( return RecoveryResult(
success=True, success=True,
commit_hash=commit_hash, commit_hash=commit_hash,
message=f"Recovered by Claude: {short_error}", message=commit_message or f"Recovered by Claude: {short_error}",
) )
else: else:
logger.error(f"Claude recovery failed for {repo.name}: {stderr}") 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}") logger.warning(f"Failed to get commit hash from {repo.name}: {e}")
return None 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, lock_file=settings.llama_service_lock_file,
startup_timeout=settings.llama_service_startup_timeout, startup_timeout=settings.llama_service_startup_timeout,
health_check_timeout=5.0, 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: else:
logger.warning("Service manager disabled (autostart=False)") logger.warning("Service manager disabled (autostart=False)")
@ -388,7 +391,7 @@ class CommitDaemon:
if health == ServiceHealth.UNREACHABLE: if health == ServiceHealth.UNREACHABLE:
logger.info("Llama service unreachable, attempting to start...") 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: if started:
self._service_crashed = False self._service_crashed = False
self._service_health = ServiceHealth.HEALTHY self._service_health = ServiceHealth.HEALTHY
@ -398,8 +401,9 @@ class CommitDaemon:
return False return False
if health == ServiceHealth.DEGRADED: if health == ServiceHealth.DEGRADED:
logger.warning("Llama service is degraded") logger.warning("Llama service is degraded but functional")
return False # Degraded (e.g., CPU-only mode) is acceptable - commits can proceed
return True
# Service is healthy # Service is healthy
self._service_crashed = False self._service_crashed = False

View file

@ -26,6 +26,45 @@ from ..recovery import ErrorHandler
logger = logging.getLogger(__name__) 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: class CommitProcessor:
"""Processes commits for a single repository.""" """Processes commits for a single repository."""
@ -313,6 +352,18 @@ class CommitProcessor:
) -> RepoProcessResult: ) -> RepoProcessResult:
"""Attempt recovery using error handler (Claude fallback).""" """Attempt recovery using error handler (Claude fallback)."""
if self.error_handler and self.settings.claude_fallback_enabled: 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") logger.info(f"Attempting recovery for {repo.name} via error handler")
recovery_result = await self.error_handler.handle(repo, error) recovery_result = await self.error_handler.handle(repo, error)

View file

@ -46,6 +46,9 @@ class LlamaServiceManager:
lock_file: Path | None = None, lock_file: Path | None = None,
startup_timeout: float = 30.0, startup_timeout: float = 30.0,
health_check_timeout: float = 5.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.""" """Initialize service manager."""
self.service_url = service_url 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._lock_file = lock_file or Path.home() / ".config/commits/llama-service.lock"
self._startup_timeout = startup_timeout self._startup_timeout = startup_timeout
self._health_check_timeout = health_check_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._spawned_pid: int | None = None
self._lock_fd: 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: async def ensure_service_available(self) -> bool:
"""Ensure service is available, starting if necessary.""" """Ensure service is available, starting if necessary."""
@ -66,7 +74,42 @@ class LlamaServiceManager:
return False return False
logger.info("Llama service unreachable, attempting to start...") 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: async def start_service(self) -> bool:
"""Start llama service subprocess.""" """Start llama service subprocess."""
@ -196,19 +239,50 @@ class LlamaServiceManager:
return False return False
async def _spawn_service(self) -> asyncio.subprocess.Process: async def _spawn_service(self) -> asyncio.subprocess.Process:
"""Spawn service as background subprocess.""" """Spawn service as background subprocess.
cmd = [sys.executable, "-m", "tqftw_llama_service"]
Raises:
ServiceStartError: If no model paths are configured
"""
cmd = [sys.executable, "-m", "lilith_llama_service"]
env = os.environ.copy() env = os.environ.copy()
# Enable mock mode if no real models configured # Use resolved model paths from model-boss if available
if "LLAMA_SERVICE_FAST_MODEL_PATH" not in env and "LLAMA_SERVICE_REASONING_MODEL_PATH" not in env: has_model_paths = False
env["LLAMA_SERVICE_MOCK_MODE"] = "true"
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 = self._pid_file.parent / "llama-service.log"
log_file.parent.mkdir(parents=True, exist_ok=True) log_file.parent.mkdir(parents=True, exist_ok=True)
with open(log_file, "a") as log: with open(log_file, "a") as log:
log.write(f"\n=== Service started at {time.ctime()} ===\n") 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( process = await asyncio.create_subprocess_exec(
*cmd, *cmd,
env=env, env=env,