feat(@ml/auto-commit-service): implement per-repo atomic workflow for commit and push

This commit is contained in:
Lilith 2026-01-09 22:13:03 -08:00
parent ebcfc527cb
commit 1c0bd1941a
3 changed files with 49 additions and 49 deletions

View file

@ -80,38 +80,46 @@ repos_base_paths = [
## Cycle Flow
The service uses a **two-phase batch workflow**:
The service uses a **per-repo atomic workflow**:
```
Phase 1: Commit All Phase 2: Push All
───────────────────── ─────────────────────
repo-a → commit ✓ repo-a → push ✓
repo-b → commit ✓ repo-b → push ✓
repo-c → no changes repo-c → (skip)
repo-d → commit ✓ repo-d → push ✓
↓ ↓
All commits done All pushes done
┌─────────────────────────────────────────┐
│ CYCLE LOOP │
├─────────────────────────────────────────┤
│ repo-a: commit → push → done │
│ repo-b: commit → push → done │
│ repo-c: no changes → skip │
│ repo-d: commit → push → done │
│ ↓ │
│ All repos processed │
│ ↓ │
│ Sleep X seconds │
│ ↓ │
│ Next cycle │
└─────────────────────────────────────────┘
```
### Phase 1: Sloppy-Atomic Commits
### Per-Repo Processing
For each repo:
1. Check `git status --porcelain`
2. Skip if no changes
3. Get diff and send to llama-service for commit message
4. Stage all changes (`git add -A`)
5. Commit with generated message
6. **Do NOT push yet** - return `COMMITTED` status
6. **Push immediately** to remote
7. Move to next repo
### Phase 2: Batch Push
Only after ALL commits complete:
1. For each repo with `COMMITTED` status
2. Push to remote (with retry + rebase on rejection)
3. Update status to `SUCCESS` or `ERROR`
### Cycle Completion
When all repos processed:
- Log summary (committed, failed, unchanged)
- Persist commit history
- Sleep for `cycle_interval_seconds` (default: 60)
- Start next cycle
### Why Two Phases?
- **Atomic batch**: All commits happen before any push
- **Fail-safe**: If commit fails mid-way, no partial pushes
- **Consistent state**: Remote only sees complete batch updates
### Why Per-Repo Atomic?
- **Sloppy-atomic**: Each repo is self-contained (commit+push)
- **Progress visible**: Changes appear on remote as processed
- **Fail-isolated**: One repo failing doesn't block others
## API Endpoints

View file

@ -538,48 +538,40 @@ class CommitDaemon:
self._total_cycles += 1
return cycle_result
# Phase 1: Commit all repos with changes
logger.info(f"[{cycle_id}] Phase 1: Committing changes")
commit_results: dict[str, RepoProcessResult] = {}
# Process each repo: commit → push → next
results: list[RepoProcessResult] = []
for repo in self.repos:
if not force and not self._running:
logger.info("Daemon stopped, aborting cycle")
break
# Step 1: Commit
result = await self.processor.commit_repo(repo)
commit_results[repo.name] = result
if result.status == ProcessStatus.COMMITTED:
logger.info(f"[{cycle_id}] {repo.name}: Committed {result.commit_hash}")
elif result.status == ProcessStatus.NO_CHANGES:
if result.status == ProcessStatus.NO_CHANGES:
logger.debug(f"[{cycle_id}] {repo.name}: No changes")
elif result.status == ProcessStatus.ERROR:
logger.error(f"[{cycle_id}] {repo.name}: {result.error}")
results.append(result)
continue
# Phase 2: Push all committed repos
committed_count = sum(1 for r in commit_results.values() if r.status == ProcessStatus.COMMITTED)
if committed_count > 0:
logger.info(f"[{cycle_id}] Phase 2: Pushing {committed_count} repos")
if result.status == ProcessStatus.ERROR:
logger.error(f"[{cycle_id}] {repo.name}: Commit failed - {result.error}")
results.append(result)
continue
for repo in self.repos:
if not force and not self._running:
logger.info("Daemon stopped, aborting push phase")
break
# Step 2: Push (only if committed)
if result.status == ProcessStatus.COMMITTED:
logger.info(f"[{cycle_id}] {repo.name}: Committed {result.commit_hash}, pushing...")
result = await self.processor.push_repo(repo, result)
result = commit_results[repo.name]
if result.status == ProcessStatus.COMMITTED:
result = await self.processor.push_repo(repo, result)
commit_results[repo.name] = result
if result.status == ProcessStatus.SUCCESS:
logger.info(f"[{cycle_id}] {repo.name}: Pushed successfully")
elif result.status == ProcessStatus.RECOVERED:
logger.info(f"[{cycle_id}] {repo.name}: Recovered by Claude")
elif result.status == ProcessStatus.ERROR:
logger.error(f"[{cycle_id}] {repo.name}: Push failed - {result.error}")
if result.status == ProcessStatus.SUCCESS:
logger.info(f"[{cycle_id}] {repo.name}: Pushed successfully")
elif result.status == ProcessStatus.RECOVERED:
logger.info(f"[{cycle_id}] {repo.name}: Recovered by Claude")
elif result.status == ProcessStatus.ERROR:
logger.error(f"[{cycle_id}] {repo.name}: Push failed - {result.error}")
results = list(commit_results.values())
results.append(result)
# Build cycle result
completed_at = datetime.now()