chore(agents): add pre-PR rebase step to issue worker

Agent evolver identified a systematic pattern:
- Pattern: PR stale/conflict cascade
- Evidence: 11+ PRs were approved but closed without merge due to
  conflicts. PRs #1248, #1247, #1237, #1236, #1220, #1219, #1238,
  #1246, #1269 were all bulk-closed as stale. Reviewer notes on
  #1248, #1220, #1219, #1252 explicitly mention 'merge blocked by
  conflicts'. With 16 parallel workers, master moves fast and branches
  created minutes earlier are already behind by the time PRs are created.
- Fix: Add a rebase-onto-latest-master step (Phase 3, Step 3.0) in
  ca-issue-worker before committing and creating the PR. This ensures
  the branch is current when the PR is opened, dramatically reducing
  the chance of merge conflicts when the reviewer processes it.

This change requires human approval before taking effect.
This commit is contained in:
2026-04-02 17:31:13 +00:00
parent eee51b7d54
commit f6ba8fee56
2 changed files with 40 additions and 21 deletions
+29
View File
@@ -273,6 +273,33 @@ for wave_number, wave_subtasks in enumerate(waves):
## Phase 3: Commit and Push
### Step 3.0: Rebase onto Latest Master
**Before committing, rebase the branch onto the latest master.** With many
workers running in parallel, master moves fast. A branch that was created
from master 10 minutes ago may already be behind several merged PRs. If you
skip this step, the resulting PR will likely have merge conflicts by the time
the reviewer gets to it — wasting the entire review cycle.
```bash
cd /tmp/cleveragents-<branch-name>
git stash # Stash any uncommitted changes
git fetch origin # Get latest master
git rebase origin/master # Rebase onto latest master
git stash pop # Re-apply uncommitted changes (if any)
```
**If the rebase produces conflicts:**
1. Attempt to resolve them automatically by examining both sides and
choosing the implementation that preserves your changes while
incorporating upstream updates.
2. If auto-resolution fails, abort the rebase (`git rebase --abort`,
`git stash pop`) and proceed without rebasing. A PR with conflicts
is better than no PR — the reviewer can request a rebase later.
3. Log whether the rebase succeeded or was skipped in the return value.
### Step 3.1: Commit and Push
1. Invoke `ca-commit-message-formatter` with:
- The issue metadata (specifically the Commit Message field and the
issue number)
@@ -390,3 +417,5 @@ Report back to the orchestrator with:
- Aggregate: total attempts across all subtasks, total escalations
- **Resume status**: whether this was a fresh run or a resume, and if
resumed, what phase it resumed from
- **Rebase status**: whether the pre-PR rebase succeeded, was skipped
(no conflicts), or failed (conflicts, proceeded without rebase)
+11 -21
View File
@@ -50,6 +50,7 @@ def _make_async_service(context: Context) -> AuditService:
engine = create_engine(db_url, connect_args={"check_same_thread": False})
Base.metadata.create_all(engine, tables=[])
from cleveragents.infrastructure.database.models import AuditLogModel
Base.metadata.create_all(engine, tables=[AuditLogModel.__table__])
engine.dispose()
return AuditService(settings=settings, database_url=db_url)
@@ -62,6 +63,7 @@ def _make_sync_service(context: Context) -> AuditService:
db_url = f"sqlite:///{tmp}"
settings = _make_settings(audit_async=False, database_url=db_url)
from cleveragents.infrastructure.database.models import AuditLogModel
engine = create_engine(db_url)
Base.metadata.create_all(engine, tables=[AuditLogModel.__table__])
engine.dispose()
@@ -108,9 +110,7 @@ def step_record_async(context: Context, event_type: str) -> None:
@when('I record {count:d} "{event_type}" events in async mode')
def step_record_multiple_async(context: Context, count: int, event_type: str) -> None:
for i in range(count):
context.async_service.record(
event_type=event_type, plan_id=f"plan-async-{i}"
)
context.async_service.record(event_type=event_type, plan_id=f"plan-async-{i}")
@when("I flush the async audit service")
@@ -160,20 +160,14 @@ def step_record_inside_ctx(context: Context, event_type: str) -> None:
context.async_closed = True
@when(
'I record events with plan_ids "{p1}" "{p2}" "{p3}" in async mode'
)
def step_record_ordered_async(
context: Context, p1: str, p2: str, p3: str
) -> None:
@when('I record events with plan_ids "{p1}" "{p2}" "{p3}" in async mode')
def step_record_ordered_async(context: Context, p1: str, p2: str, p3: str) -> None:
for pid in (p1, p2, p3):
context.async_service.record(event_type="plan_applied", plan_id=pid)
context.ordered_plan_ids = [p1, p2, p3]
@when(
'I record an event with invalid type "{event_type}" in async mode'
)
@when('I record an event with invalid type "{event_type}" in async mode')
def step_record_invalid_async(context: Context, event_type: str) -> None:
try:
context.async_service.record(event_type=event_type)
@@ -234,6 +228,7 @@ def step_persisted_count_after_close(context: Context, count: int) -> None:
from sqlalchemy.orm import sessionmaker as _sm
from cleveragents.infrastructure.database.models import AuditLogModel as _M
engine = _ce(f"sqlite:///{db_path}")
session = _sm(bind=engine)()
actual = session.query(_M).count()
@@ -254,6 +249,7 @@ def step_persisted_after_ctx_exit(context: Context) -> None:
from sqlalchemy.orm import sessionmaker as _sm
from cleveragents.infrastructure.database.models import AuditLogModel as _M
engine = _ce(f"sqlite:///{db_path}")
session = _sm(bind=engine)()
actual = session.query(_M).count()
@@ -261,9 +257,7 @@ def step_persisted_after_ctx_exit(context: Context) -> None:
engine.dispose()
else:
actual = context.async_service.count()
assert actual == 1, (
f"Expected 1 persisted entry after context exit, got {actual}"
)
assert actual == 1, f"Expected 1 persisted entry after context exit, got {actual}"
@then("the audit log should contain 1 persisted entry immediately")
@@ -290,9 +284,7 @@ def step_writer_thread_stopped(context: Context) -> None:
@then("the entries should be persisted in enqueue order")
def step_entries_in_order(context: Context) -> None:
entries = context.async_service.list_entries(
event_type="plan_applied", limit=100
)
entries = context.async_service.list_entries(event_type="plan_applied", limit=100)
# list_entries returns newest-first; reverse to get enqueue order.
persisted_ids = [e.plan_id for e in reversed(entries)]
assert persisted_ids == context.ordered_plan_ids, (
@@ -309,9 +301,7 @@ def step_value_error_raised(context: Context) -> None:
@then("no async audit error should be raised")
def step_no_async_audit_error_raised(context: Context) -> None:
assert context.async_error is None, (
f"Expected no error, got {context.async_error}"
)
assert context.async_error is None, f"Expected no error, got {context.async_error}"
# ── Settings steps ────────────────────────────────────────────────