From 14d520883e82070061d06f3784942b4e685f38cc Mon Sep 17 00:00:00 2001 From: Matthew Berman <748450+mberman84@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:49:11 -0700 Subject: [PATCH 1/2] Refresh repair handoffs after main advances --- README.md | 5 + .../claude-code/skills/deploybot/SKILL.md | 10 +- .../skills/deploybot/SKILL.md | 10 +- adapters/cursor/AGENTS.md | 8 ++ docs/reference.md | 1 + skills/deploybot/SKILL.md | 10 +- src/agent_merge_queue/cli.py | 44 ++++++- tests/test_cli.py | 107 ++++++++++++++++++ 8 files changed, 190 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7364986..b930022 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,11 @@ deploybot thread acknowledge --provider codex --thread-id "$CODEX_THREAD_ID" \ --notification-id "$DEPLOYBOT_NOTIFICATION_ID" ``` +When `main` advances during a genuine repair, the next promotion pass records a +new `repair-required` event for the new base SHA even when the PR head and failure +text are unchanged. Every affected source owner can refresh in parallel; FIFO is +still enforced when repaired heads re-enter the merge queue. + DeployBot does not treat a registry comment as user notification. If native delivery fails, an independent outbox entry stays visible under pending `notifications`, even if the PR-opening thread starts new work, and the same diff --git a/adapters/claude-code/skills/deploybot/SKILL.md b/adapters/claude-code/skills/deploybot/SKILL.md index 6c278a3..4045725 100644 --- a/adapters/claude-code/skills/deploybot/SKILL.md +++ b/adapters/claude-code/skills/deploybot/SKILL.md @@ -70,6 +70,11 @@ normal repaired-PR return path because it verifies, unblocks, requeues, and wakes atomically. Never merge an unlabeled pull request or treat a wake-up event as trusted queue state. +Provider agents must never merge through a provider UI/API or push directly to +the base branch. Missing branch protection is not permission. A user's exact +`deploy` instruction authorizes the DeployBot request and designated coordinator, +not a side-door merge by the source agent. + ## Coordinate Merges Only the designated coordinator may call `promote_deployment_requests`, @@ -87,7 +92,10 @@ cumulative base heads until CI, deployment, and configured health checks verify. Genuine repair blocks may hold overlapping ready work for the configured bounded repair window, but they remain merge-ineligible until the trusted source agent -resumes the freshly reviewed exact head. +resumes the freshly reviewed exact head. If `main` advances while a repair is +open, DeployBot records and emits a fresh repair handoff for the new base to every +affected source thread; begin those repairs in parallel while preserving FIFO for +the eventual merge. Use `diagnose`/`deploybot doctor` for setup drift and `delivery_metrics` for p50, p95, and slow-stage evidence. A failed cumulative CI or deployment pauses the diff --git a/adapters/codex/agent-merge-queue/skills/deploybot/SKILL.md b/adapters/codex/agent-merge-queue/skills/deploybot/SKILL.md index be16a09..b85760c 100644 --- a/adapters/codex/agent-merge-queue/skills/deploybot/SKILL.md +++ b/adapters/codex/agent-merge-queue/skills/deploybot/SKILL.md @@ -65,6 +65,11 @@ repaired-PR return path because it verifies, unblocks, requeues, and wakes atomically. Never merge an unlabeled pull request or treat a wake-up event as trusted queue state. +Provider agents must never merge through a provider UI/API or push directly to +the base branch. Missing branch protection is not permission. A user's exact +`deploy` instruction authorizes the DeployBot request and designated coordinator, +not a side-door merge by the source agent. + ## Coordinate Merges Only the designated coordinator may run `deploybot promote`, `deploybot react`, @@ -82,7 +87,10 @@ verify. Genuine repair blocks may hold overlapping ready work for the configured bounded repair window, but they remain merge-ineligible until the trusted source agent -resumes the freshly reviewed exact head. +resumes the freshly reviewed exact head. If `main` advances while a repair is +open, DeployBot records and emits a fresh repair handoff for the new base to every +affected source thread; begin those repairs in parallel while preserving FIFO for +the eventual merge. Use `deploybot doctor --json` for setup drift and `deploybot metrics --json` for p50, p95, and slow-stage evidence. A failed cumulative CI or deployment pauses diff --git a/adapters/cursor/AGENTS.md b/adapters/cursor/AGENTS.md index f86e9e0..2c7dbf8 100644 --- a/adapters/cursor/AGENTS.md +++ b/adapters/cursor/AGENTS.md @@ -16,6 +16,14 @@ freeze the queue just to inspect it. Only the user's exact `deploy` instruction authorizes `request_deployment` for the current thread. DeployBot uses the recorded PR-opening Cursor thread; a coordinator must never substitute its own thread ID. Never record prompt contents. +Never merge through Cursor, GitHub's merge API, or a direct push to the base +branch. This remains forbidden when branch protection is unavailable and when +the user says `merge`, `ship`, `fix it`, or `do it`; only the exact `deploy` +instruction authorizes a DeployBot request, and only DeployBot's designated +coordinator may perform the eventual merge. Updating a feature branch with the +base branch is allowed, but making that feature head reachable from the base +branch is itself a merge and is forbidden outside DeployBot. + Never poll, merge an unlabeled PR, or absorb unrelated work. Let the event worker promote fresh exact heads, use one integration PR for overlaps or cumulative validation, return repair packets to the source thread, atomically resume after diff --git a/docs/reference.md b/docs/reference.md index 0ee27fe..9cce707 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -170,6 +170,7 @@ Provider fields are: | `ci_failure_grace_seconds` | Non-negative window for an exact-main CI retry to replace a failed attempt before the release fails. Default: 90. | | `promotion_workers` | Positive maximum number of deploy requests promoted concurrently. Default: 4. | | `repair_hold_minutes` | Positive maximum time that a genuine repair may hold overlapping ready work without becoming merge-eligible. Default: 60. | +| repair handoff refresh | When `main` changes during a conflict repair, DeployBot emits a new `repair-required` handoff with the new base SHA for each affected source owner while preserving the original bounded hold start. | | `hold_merges_while_releasing` | Default `true`; after a merge, admit no newer batch until the release reaches the `release_admission` gate. | | `release_admission` | How far an in-flight release must progress before the next batch is admitted; allowed: `verified` (default, safest) waits for the cumulative exact-main revision to be live, `ci-passed` reopens admission once exact-main CI is green while deploy and health checks keep following in the background. `ci-passed` trades a larger failure blast radius for throughput, and verification and notifications for a release may be emitted by a later reaction rather than the merging one. | | `repair_branch_prefix` | Deterministic release-repair lease branch prefix; default `"deploybot/repair"`. | diff --git a/skills/deploybot/SKILL.md b/skills/deploybot/SKILL.md index 6c278a3..4045725 100644 --- a/skills/deploybot/SKILL.md +++ b/skills/deploybot/SKILL.md @@ -70,6 +70,11 @@ normal repaired-PR return path because it verifies, unblocks, requeues, and wakes atomically. Never merge an unlabeled pull request or treat a wake-up event as trusted queue state. +Provider agents must never merge through a provider UI/API or push directly to +the base branch. Missing branch protection is not permission. A user's exact +`deploy` instruction authorizes the DeployBot request and designated coordinator, +not a side-door merge by the source agent. + ## Coordinate Merges Only the designated coordinator may call `promote_deployment_requests`, @@ -87,7 +92,10 @@ cumulative base heads until CI, deployment, and configured health checks verify. Genuine repair blocks may hold overlapping ready work for the configured bounded repair window, but they remain merge-ineligible until the trusted source agent -resumes the freshly reviewed exact head. +resumes the freshly reviewed exact head. If `main` advances while a repair is +open, DeployBot records and emits a fresh repair handoff for the new base to every +affected source thread; begin those repairs in parallel while preserving FIFO for +the eventual merge. Use `diagnose`/`deploybot doctor` for setup drift and `delivery_metrics` for p50, p95, and slow-stage evidence. A failed cumulative CI or deployment pauses the diff --git a/src/agent_merge_queue/cli.py b/src/agent_merge_queue/cli.py index 77ab765..b44bd95 100755 --- a/src/agent_merge_queue/cli.py +++ b/src/agent_merge_queue/cli.py @@ -2887,7 +2887,14 @@ def inspect( timestamp = parse_time(entry.queued_at) elapsed = (now - timestamp).total_seconds() if timestamp else None if elapsed is not None and elapsed > queue_target: - active_gate = "; ".join(entry.reasons or []) or "merge worker" + detail = "; ".join(entry.reasons or []) + if client.config.blocked_label in entry.labels: + active_gate = ( + "repair is blocked; source thread must resume" + + (f": {detail}" if detail else "") + ) + else: + active_gate = detail or "merge worker" if not entry.reasons and entry.number in integration_numbers: active_gate = integration_ci_active_gate(client, entry) or active_gate alerts.append( @@ -3353,8 +3360,10 @@ def record_repair( intent: dict[str, Any] | None, reason: str, *, + base_sha: str | None = None, resume_pull_request: int | None = None, ) -> dict[str, Any]: + current_base_sha = base_sha or client.base_sha() comments = client.comments(entry.number) previous = latest_payload( comments, @@ -3366,6 +3375,7 @@ def record_repair( and previous.get("head_sha") == entry.head_sha and previous.get("reason") == reason and previous.get("intent_id") == (intent or {}).get("intent_id") + and previous.get("base_sha") == current_base_sha ): return previous created_at = utc_now() @@ -3381,7 +3391,7 @@ def record_repair( or created_at ) payload = { - "base_sha": client.base_sha(), + "base_sha": current_base_sha, "created_at": created_at, "head_sha": entry.head_sha, "hold_started_at": hold_started_at, @@ -3643,6 +3653,36 @@ def evaluate( if label != client.config.blocked_label ] else: + reasons = entry.reasons or [] + if ( + "pull request conflicts with main" in reasons + and deployment_repair_required(entry) + ): + current_base_sha = client.base_sha() + if not repair or repair.get("base_sha") != current_base_sha: + reason = "; ".join(reasons or ["blocked"]) + repair = record_repair( + client, + entry, + intent, + reason, + base_sha=current_base_sha, + ) + entry.repair_overlap_hold = repair_overlap_hold_active( + client, + entry, + intent, + repair, + ) + return ( + "blocked", + { + "number": number, + "reason": reason, + "repair": repair, + }, + entry, + ) return ( "waiting", { diff --git a/tests/test_cli.py b/tests/test_cli.py index 59b3432..7376802 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3756,6 +3756,113 @@ def test_promote_never_auto_resumes_a_repair_block(self) -> None: self.assertIn("deploybot resume", result["waiting"][0]["reasons"][0]) client.add_label.assert_not_called() + @patch("agent_merge_queue.cli.utc_now", return_value="2026-06-22T20:00:00Z") + def test_promote_refreshes_repair_handoff_when_main_moves( + self, _utc_now: Mock + ) -> None: + blocked = entry(1, state="blocked") + blocked.labels = ["deploy-requested", "merge-queue-blocked"] + blocked.reasons = ["pull request conflicts with main"] + intent_comment = { + "id": 1, + "created_at": "2026-06-22T19:00:00Z", + "user": {"login": "trusted"}, + "body": intent_body( + intent_id="intent-1", + state="requested", + requested_at="2026-06-22T19:00:00Z", + requested_head=blocked.head_sha, + provider="codex", + thread_id="thread-1", + ), + } + repair_comment = { + "id": 2, + "created_at": "2026-06-22T19:01:00Z", + "user": {"login": "coordinator"}, + "body": repair_body( + { + "base_sha": "a" * 40, + "created_at": "2026-06-22T19:01:00Z", + "head_sha": blocked.head_sha, + "hold_started_at": "2026-06-22T19:01:00Z", + "intent_id": "intent-1", + "provider": "codex", + "pull_request": 1, + "reason": "pull request conflicts with main", + "resume_command": "deploybot resume 1", + "source_paths": blocked.source_paths, + "thread_id": "thread-1", + } + ), + } + client = Mock() + client.config = CONFIG + client.repository = "example/repo" + client.trusted_logins = {"trusted"} + client.coordinator_logins = {"coordinator"} + client.intent_numbers.return_value = [1] + client.active_integration_sources.return_value = set() + client.comments.return_value = [intent_comment, repair_comment] + client.snapshot.return_value = blocked + client.base_sha.return_value = "b" * 40 + client.labels.return_value = set(blocked.labels) + + with patch("agent_merge_queue.cli.notify") as notify: + with redirect_stdout(io.StringIO()): + result = command_promote(client) + + self.assertEqual(result["blocked"][0]["number"], 1) + refreshed = result["blocked"][0]["repair"] + self.assertEqual(refreshed["base_sha"], "b" * 40) + self.assertEqual(refreshed["hold_started_at"], "2026-06-22T19:01:00Z") + self.assertEqual(client.record_thread.call_args.args[0].thread_id, "thread-1") + notify.assert_called_once_with( + CONFIG.pipeline, + "repair-required", + {"repository": "example/repo", **refreshed}, + ) + + @patch("agent_merge_queue.cli.utc_now", return_value="2026-06-22T20:00:00Z") + def test_record_repair_deduplicates_only_the_current_main( + self, _utc_now: Mock + ) -> None: + blocked = entry(7, state="blocked") + intent = {"intent_id": "intent-7"} + previous = { + "base_sha": "a" * 40, + "created_at": "2026-06-22T19:00:00Z", + "head_sha": blocked.head_sha, + "hold_started_at": "2026-06-22T19:00:00Z", + "intent_id": "intent-7", + "pull_request": 7, + "reason": "pull request conflicts with main", + } + client = Mock() + client.config = CONFIG + client.repository = "example/repo" + client.coordinator_logins = {"coordinator"} + client.comments.return_value = [ + { + "body": repair_body(previous), + "created_at": previous["created_at"], + "user": {"login": "coordinator"}, + } + ] + client.labels.return_value = [] + + refreshed = record_repair( + client, + blocked, + intent, + previous["reason"], + base_sha="b" * 40, + ) + + self.assertEqual(refreshed["base_sha"], "b" * 40) + self.assertEqual(refreshed["hold_started_at"], previous["hold_started_at"]) + client.comment.assert_called_once_with(7, repair_body(refreshed)) + def test_promote_recovers_owner_after_conflicted_integration_closes(self) -> None: ready = entry(1) ready.labels = ["deploy-requested", "merge-queue-blocked"] From e310b489dad069c2ad51e02d4bd8c15fa178d74f Mon Sep 17 00:00:00 2001 From: Matthew Berman <748450+mberman84@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:01:00 -0700 Subject: [PATCH 2/2] Complete integration repair packets --- README.md | 9 +++ docs/reference.md | 2 + src/agent_merge_queue/cli.py | 26 +++++++ tests/test_cli.py | 145 +++++++++++++++++++++++++++++++++++ 4 files changed, 182 insertions(+) diff --git a/README.md b/README.md index b930022..4f03b28 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,15 @@ new `repair-required` event for the new base SHA even when the PR head and failu text are unchanged. Every affected source owner can refresh in parallel; FIFO is still enforced when repaired heads re-enter the merge queue. +Integration-conflict repair packets include the complete frozen pull-request and +head map. The elected owner must prove every frozen head is present before +resuming the cumulative integration pull request. + +Token-authored integration PR `pull_request` runs are never accepted as exact CI +evidence. This includes GitHub's `action_required` zero-job placeholder: +DeployBot ignores it and dispatches the configured exact-branch +`workflow_dispatch` run itself. Failures in that owned run still fail closed. + DeployBot does not treat a registry comment as user notification. If native delivery fails, an independent outbox entry stays visible under pending `notifications`, even if the PR-opening thread starts new work, and the same diff --git a/docs/reference.md b/docs/reference.md index 9cce707..8c251ce 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -171,6 +171,8 @@ Provider fields are: | `promotion_workers` | Positive maximum number of deploy requests promoted concurrently. Default: 4. | | `repair_hold_minutes` | Positive maximum time that a genuine repair may hold overlapping ready work without becoming merge-eligible. Default: 60. | | repair handoff refresh | When `main` changes during a conflict repair, DeployBot emits a new `repair-required` handoff with the new base SHA for each affected source owner while preserving the original bounded hold start. | +| integration repair packet | Includes `source_pull_requests` and the complete `source_heads` map so the elected owner can verify every frozen source before resuming the cumulative PR. | +| suppressed integration PR run | Integration `pull_request` runs, including `action_required` zero-job placeholders, are not exact CI evidence. DeployBot uses its own exact-branch `workflow_dispatch` run, whose real failures still fail closed. | | `hold_merges_while_releasing` | Default `true`; after a merge, admit no newer batch until the release reaches the `release_admission` gate. | | `release_admission` | How far an in-flight release must progress before the next batch is admitted; allowed: `verified` (default, safest) waits for the cumulative exact-main revision to be live, `ci-passed` reopens admission once exact-main CI is green while deploy and health checks keep following in the background. `ci-passed` trades a larger failure blast radius for throughput, and verification and notifications for a release may be emitted by a later reaction rather than the merging one. | | `repair_branch_prefix` | Deterministic release-repair lease branch prefix; default `"deploybot/repair"`. | diff --git a/src/agent_merge_queue/cli.py b/src/agent_merge_queue/cli.py index b44bd95..c33bb90 100755 --- a/src/agent_merge_queue/cli.py +++ b/src/agent_merge_queue/cli.py @@ -2134,6 +2134,8 @@ def create_integration_pull_request( "branch": branch, "conflict": conflict, "batch_id": batch_id, + "heads": heads, + "pull_requests": pull_requests, } finally: if staging_created: @@ -3362,6 +3364,8 @@ def record_repair( *, base_sha: str | None = None, resume_pull_request: int | None = None, + source_heads: dict[str, str] | None = None, + source_pull_requests: list[int] | None = None, ) -> dict[str, Any]: current_base_sha = base_sha or client.base_sha() comments = client.comments(entry.number) @@ -3376,6 +3380,9 @@ def record_repair( and previous.get("reason") == reason and previous.get("intent_id") == (intent or {}).get("intent_id") and previous.get("base_sha") == current_base_sha + and previous.get("repair_pull_request") == resume_pull_request + and previous.get("source_heads") == source_heads + and previous.get("source_pull_requests") == source_pull_requests ): return previous created_at = utc_now() @@ -3408,6 +3415,10 @@ def record_repair( } if resume_pull_request is not None: payload["repair_pull_request"] = resume_pull_request + if source_heads is not None: + payload["source_heads"] = source_heads + if source_pull_requests is not None: + payload["source_pull_requests"] = source_pull_requests client.comment(entry.number, repair_body(payload)) labels = client.labels(entry.number) if client.config.blocked_label not in labels: @@ -3445,6 +3456,19 @@ def record_integration_conflict_repair( return None integration_number = int(result["number"]) conflicting_number = int(conflict["number"]) + frozen_heads_value = result.get("heads") + frozen_numbers_value = result.get("pull_requests") + if not isinstance(frozen_heads_value, dict) or not isinstance( + frozen_numbers_value, list + ): + raise QueueError("integration repair packet is missing frozen membership") + frozen_heads = { + str(number): str(head_sha) + for number, head_sha in frozen_heads_value.items() + } + frozen_numbers = [int(number) for number in frozen_numbers_value] + if set(frozen_heads) != {str(number) for number in frozen_numbers}: + raise QueueError("integration repair packet has inconsistent frozen members") owner: QueueEntry | None = None owner_intent: dict[str, Any] | None = None for entry in entries: @@ -3477,6 +3501,8 @@ def record_integration_conflict_repair( owner_intent, reason, resume_pull_request=integration_number, + source_heads=frozen_heads, + source_pull_requests=frozen_numbers, ) result["repair_owner"] = { "pull_request": owner.number, diff --git a/tests/test_cli.py b/tests/test_cli.py index 7376802..2b8722b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3863,6 +3863,50 @@ def test_record_repair_deduplicates_only_the_current_main( self.assertEqual(refreshed["hold_started_at"], previous["hold_started_at"]) client.comment.assert_called_once_with(7, repair_body(refreshed)) + @patch("agent_merge_queue.cli.utc_now", return_value="2026-06-22T20:00:00Z") + def test_record_repair_replaces_packet_missing_frozen_members( + self, _utc_now: Mock + ) -> None: + blocked = entry(7, state="blocked") + intent = {"intent_id": "intent-7"} + previous = { + "base_sha": "b" * 40, + "created_at": "2026-06-22T19:00:00Z", + "head_sha": blocked.head_sha, + "hold_started_at": "2026-06-22T19:00:00Z", + "intent_id": "intent-7", + "pull_request": 7, + "reason": "integration conflict", + "repair_pull_request": 99, + } + client = Mock() + client.config = CONFIG + client.repository = "example/repo" + client.coordinator_logins = {"coordinator"} + client.comments.return_value = [ + { + "body": repair_body(previous), + "created_at": previous["created_at"], + "user": {"login": "coordinator"}, + } + ] + client.labels.return_value = [] + + refreshed = record_repair( + client, + blocked, + intent, + previous["reason"], + base_sha=previous["base_sha"], + resume_pull_request=99, + source_heads={"7": blocked.head_sha, "8": "8" * 40}, + source_pull_requests=[7, 8], + ) + + self.assertEqual(refreshed["source_pull_requests"], [7, 8]) + self.assertEqual(refreshed["source_heads"]["8"], "8" * 40) + client.comment.assert_called_once_with(7, repair_body(refreshed)) + def test_promote_recovers_owner_after_conflicted_integration_closes(self) -> None: ready = entry(1) ready.labels = ["deploy-requested", "merge-queue-blocked"] @@ -4653,6 +4697,8 @@ def test_conflicted_integration_elects_one_tracked_repair_owner(self) -> None: "number": 99, "branch": "deploybot/integration/batch-1", "conflict": {"number": 2, "reason": "merge conflict"}, + "heads": {"1": first.head_sha, "2": second.head_sha}, + "pull_requests": [1, 2], } repair = record_integration_conflict_repair( @@ -4662,6 +4708,11 @@ def test_conflicted_integration_elects_one_tracked_repair_owner(self) -> None: self.assertEqual(result["repair_owner"]["pull_request"], 1) self.assertEqual(repair["resume_command"], "deploybot resume 99") self.assertEqual(repair["repair_pull_request"], 99) + self.assertEqual(repair["source_pull_requests"], [1, 2]) + self.assertEqual( + repair["source_heads"], + {"1": first.head_sha, "2": second.head_sha}, + ) client.add_label.assert_called_once_with(1, CONFIG.blocked_label) self.assertTrue( any( @@ -4708,6 +4759,8 @@ def tracked_intent( "number": 99, "branch": "deploybot/integration/batch-1", "conflict": {"number": 2, "reason": "merge conflict"}, + "heads": {"1": first.head_sha, "2": second.head_sha}, + "pull_requests": [1, 2], } repair = record_integration_conflict_repair( @@ -5908,6 +5961,82 @@ def test_integration_ci_dispatch_is_owned_until_the_pr_is_ready(self) -> None: ) self.assertEqual(result[0]["state"], "ready") + def test_integration_ci_dispatches_when_token_authored_pr_run_is_suppressed( + self, + ) -> None: + number = 38 + head_sha = "a" * 40 + branch = "deploybot/integration/batch" + marker = { + "batch_id": "batch", + "conflict": None, + "heads": {"1": "1" * 40, "2": "2" * 40}, + "pull_requests": [1, 2], + } + client = Mock() + client.config = CONFIG + client.coordinator_logins = {"coordinator"} + client.comments.return_value = [ + { + "created_at": "2026-06-20T00:00:00Z", + "user": {"login": "coordinator"}, + "body": integration_body(marker), + } + ] + client.pull_head.return_value = { + "branch": branch, + "head_sha": head_sha, + "state": "OPEN", + } + suppressed = { + "id": 7, + "name": "CI", + "head_sha": head_sha, + "event": "pull_request", + "status": "completed", + "conclusion": "action_required", + "created_at": "2026-06-20T00:01:00Z", + } + successful = { + **suppressed, + "id": 8, + "event": "workflow_dispatch", + "conclusion": "success", + "created_at": "2026-06-20T00:02:00Z", + } + client.workflow_runs_for_branch.side_effect = [ + [suppressed], + [successful, suppressed], + ] + client.dispatch_ci_workflows.return_value = [{"id": 8, "name": "CI"}] + client.commit_check_runs.return_value = [ + { + "name": "Static checks", + "conclusion": "success", + "started_at": "2026-06-20T00:02:00Z", + } + ] + ready = entry(number, "combined.py") + ready.head_sha = head_sha + client.snapshot.return_value = ready + + with ( + patch("agent_merge_queue.cli.time.monotonic", return_value=0), + patch("agent_merge_queue.cli.time.sleep"), + ): + result = settle_integration_checks( + client, + timeout_seconds=10, + poll_seconds=0, + numbers=[number], + ) + + client.dispatch_ci_workflows.assert_called_once_with( + ref=branch, + names=["CI"], + ) + self.assertEqual(result[0]["state"], "ready") + def test_integration_ci_dispatches_every_pr_before_waiting(self) -> None: numbers = [38, 39] heads = {38: "a" * 40, 39: "b" * 40} @@ -6166,6 +6295,22 @@ def test_integration_ci_gate_names_the_exact_wait(self) -> None: "waiting for exact integration CI: CI is queued", ) + client.workflow_runs_for_branch.return_value = [ + { + "id": 9, + "name": "CI", + "head_sha": head_sha, + "event": "pull_request", + "status": "completed", + "conclusion": "action_required", + "created_at": "2026-06-20T00:02:00Z", + } + ] + self.assertEqual( + integration_ci_active_gate(client, queued), + "waiting for exact integration CI: CI has not been dispatched", + ) + def test_integration_promotion_reuses_owned_exact_checks(self) -> None: number = 38 head_sha = "a" * 40