From 1c0bd1941ab4470d2ac315402ea65ada047b3345 Mon Sep 17 00:00:00 2001 From: Lilith Date: Fri, 9 Jan 2026 22:13:03 -0800 Subject: [PATCH] =?UTF-8?q?feat(@ml/auto-commit-service):=20=E2=9C=A8=20im?= =?UTF-8?q?plement=20per-repo=20atomic=20workflow=20for=20commit=20and=20p?= =?UTF-8?q?ush?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architecture.md | 48 ++++++++++------- .../__pycache__/daemon.cpython-312.pyc | Bin 29673 -> 28726 bytes src/auto_commit_service/scheduler/daemon.py | 50 ++++++++---------- 3 files changed, 49 insertions(+), 49 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 77dcca8..c695214 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/src/auto_commit_service/scheduler/__pycache__/daemon.cpython-312.pyc b/src/auto_commit_service/scheduler/__pycache__/daemon.cpython-312.pyc index a3b5b8a5ce3f6bd4e9ca64d9faff41390429d075..f1146495a876ceb08352502c3bbae6ab4fff96b6 100644 GIT binary patch delta 1977 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 diff --git a/src/auto_commit_service/scheduler/daemon.py b/src/auto_commit_service/scheduler/daemon.py index 92a5f6a..66e3d0b 100644 --- a/src/auto_commit_service/scheduler/daemon.py +++ b/src/auto_commit_service/scheduler/daemon.py @@ -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()