mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Fix different project same PR number collision problem
Fix (two layers): 1. Root cause — pr_merge and rebase_pr_for_task now take a required project_id and scope the lookup where(pr_number == X AND project_id == Y). Required so no caller can forget — the bug class can't recur. All 4 call sites updated (choreographer cell_pm_complete, the rebase-retry, the superseded close_pull_request now passes project_id, and _verb_runner._do_pr_merge). 2. Crash guard — _finalize_cell_complete None-checks the complete() return and returns a clean invalid_state envelope (with a remediate hint) instead of dereffing None → 500 → respawn loop.
This commit is contained in:
@@ -318,10 +318,62 @@ async def test_cell_pm_complete_merges_then_completes() -> None:
|
||||
assert env.error is None
|
||||
assert env.status == "completed"
|
||||
git_svc.pr_merge.assert_awaited_once_with(
|
||||
8, target="feature/main_pm/abc", actor_agent_id=pm_id
|
||||
8,
|
||||
target="feature/main_pm/abc",
|
||||
project_id=t.project_id,
|
||||
actor_agent_id=pm_id,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cell_pm_complete_does_not_500_when_complete_returns_none() -> None:
|
||||
"""`complete()` returns None when its PR-merged guard fails — which is
|
||||
exactly what happened live when the merge recorded against the WRONG
|
||||
task's work session (the cross-repo pr_number collision) left this
|
||||
task's `pr_status="open"`. `_finalize_cell_complete` used to deref
|
||||
`t.status` on the None and 500, leaving the cell PM thrashing
|
||||
escalate<->blocked until the CEO intervened. It must fail closed into a
|
||||
clean invalid_state envelope, never a 500.
|
||||
"""
|
||||
pm_id = uuid4()
|
||||
task_id = uuid4()
|
||||
parent_id = uuid4()
|
||||
t = MagicMock(
|
||||
id=task_id,
|
||||
status="awaiting_pm_review",
|
||||
assigned_to=pm_id,
|
||||
pr_number=8,
|
||||
branch_name="feature/backend/abc--def",
|
||||
parent_task_id=parent_id,
|
||||
team="backend",
|
||||
)
|
||||
parent = MagicMock(
|
||||
id=parent_id, branch_name="feature/main_pm/abc", parent_task_id=None
|
||||
)
|
||||
task_svc = AsyncMock()
|
||||
task_svc.get.side_effect = lambda tid: parent if tid == parent_id else t
|
||||
task_svc.all_subtasks_terminal.return_value = True
|
||||
task_svc.cell_pm_complete.return_value = None # complete() rejected
|
||||
git_svc = AsyncMock()
|
||||
git_svc.pr_merge.return_value = {"merge_commit_sha": "merge-abc"}
|
||||
journal_svc = AsyncMock()
|
||||
journal_svc.has_decision_for_task.return_value = True
|
||||
journal_svc.latest_decision_at.return_value = datetime.now(UTC)
|
||||
journal_svc.has_reflect_for_task.return_value = True
|
||||
deps = _make_deps(task=task_svc, git=git_svc, journal=journal_svc)
|
||||
c = Choreographer(deps)
|
||||
|
||||
env = await c.cell_pm_complete(pm_id, task_id, notes="reviewed and approved")
|
||||
body = env.as_dict()
|
||||
assert body["error"] == "invalid_state"
|
||||
assert (
|
||||
"merged" in body.get("message", "").lower()
|
||||
or "complete" in body.get("message", "").lower()
|
||||
)
|
||||
# No AttributeError 500 — the verb completed cleanly with a remediation.
|
||||
assert body.get("remediate")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cell_pm_complete_blocks_if_subtasks_unfinished() -> None:
|
||||
pm_id = uuid4()
|
||||
|
||||
@@ -62,7 +62,9 @@ async def test_superseded_closes_pr_and_completes_without_merge(
|
||||
return_value=MagicMock(status="completed", parent_task_id=None, team="frontend")
|
||||
)
|
||||
choreo = _choreo(task, git, monkeypatch)
|
||||
t = MagicMock(pr_number=159, parent_task_id=None, team="frontend")
|
||||
t = MagicMock(
|
||||
pr_number=159, project_id=uuid4(), parent_task_id=None, team="frontend"
|
||||
)
|
||||
|
||||
env = await choreo._resolve_merge_conflict_on_complete(
|
||||
uuid4(), uuid4(), t, "feature/frontend/root--cell", "notes", _EXC
|
||||
@@ -93,7 +95,9 @@ async def test_rebased_retries_merge_and_completes(
|
||||
return_value=MagicMock(status="completed", parent_task_id=None, team="frontend")
|
||||
)
|
||||
choreo = _choreo(task, git, monkeypatch)
|
||||
t = MagicMock(pr_number=160, parent_task_id=None, team="backend")
|
||||
t = MagicMock(
|
||||
pr_number=160, project_id=uuid4(), parent_task_id=None, team="backend"
|
||||
)
|
||||
|
||||
await choreo._resolve_merge_conflict_on_complete(
|
||||
uuid4(), uuid4(), t, "feature/backend/root--cell", "notes", _EXC
|
||||
@@ -124,7 +128,9 @@ async def test_genuine_conflict_escalates_to_ceo_and_does_not_loop(
|
||||
notify = AsyncMock()
|
||||
monkeypatch.setattr(choreo, "_notify_ceo_merge_conflict", notify)
|
||||
tid = uuid4()
|
||||
t = MagicMock(pr_number=160, parent_task_id=None, team="backend")
|
||||
t = MagicMock(
|
||||
pr_number=160, project_id=uuid4(), parent_task_id=None, team="backend"
|
||||
)
|
||||
|
||||
env = await choreo._resolve_merge_conflict_on_complete(
|
||||
uuid4(), tid, t, "feature/backend/root--cell", "notes", _EXC
|
||||
@@ -155,7 +161,9 @@ async def test_unknown_rebase_outcome_escalates_rather_than_completing(
|
||||
task.admin_set_status = AsyncMock()
|
||||
task.get = AsyncMock(return_value=MagicMock(status="awaiting_ceo_approval"))
|
||||
choreo = _choreo(task, git, monkeypatch)
|
||||
t = MagicMock(pr_number=160, parent_task_id=None, team="backend")
|
||||
t = MagicMock(
|
||||
pr_number=160, project_id=uuid4(), parent_task_id=None, team="backend"
|
||||
)
|
||||
|
||||
await choreo._resolve_merge_conflict_on_complete(
|
||||
uuid4(), uuid4(), t, "feature/backend/root--cell", "notes", _EXC
|
||||
|
||||
Reference in New Issue
Block a user