feat(@ml/auto-commit-service): ✨ implement per-repo atomic workflow for commit and push
This commit is contained in:
parent
ebcfc527cb
commit
1c0bd1941a
3 changed files with 49 additions and 49 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue