From 1170353958fb31718977dfff465ce9e1a89b8db4 Mon Sep 17 00:00:00 2001 From: Matthew Berman <748450+mberman84@users.noreply.github.com> Date: Sun, 21 Jun 2026 17:57:28 -0700 Subject: [PATCH 1/2] Dispatch integration CI concurrently --- README.md | 5 + .../claude-code/.claude-plugin/plugin.json | 2 +- .../.codex-plugin/plugin.json | 2 +- docs/reference.md | 2 +- pyproject.toml | 2 +- src/agent_merge_queue/__init__.py | 2 +- src/agent_merge_queue/cli.py | 205 +++++++++++------ tests/test_cli.py | 206 ++++++++++++++++++ 8 files changed, 357 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 51b33f5..0af1f63 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,11 @@ controller recognizes them: In `overlap` mode, a ready source waits when another active, near-ready intent belongs to the same source-overlap component. Unrelated ready work still drains, and the held component freezes together once its remaining gates pass. +When more than one cumulative integration pull request needs controller-owned +exact-head CI, DeployBot dispatches every missing workflow before it waits. The +workflows then run in parallel instead of making later batches wait for an +earlier runner delay. Slow-queue status names the missing, queued, or failed +exact integration workflow instead of reporting only a generic merge worker. ```bash deploybot resume diff --git a/adapters/claude-code/.claude-plugin/plugin.json b/adapters/claude-code/.claude-plugin/plugin.json index d53806f..eb109d2 100644 --- a/adapters/claude-code/.claude-plugin/plugin.json +++ b/adapters/claude-code/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "deploybot", - "version": "0.2.14", + "version": "0.2.15", "description": "DeployBot: a provider-neutral GitHub merge queue for coding agents", "author": { "name": "DeployBot contributors" diff --git a/adapters/codex/agent-merge-queue/.codex-plugin/plugin.json b/adapters/codex/agent-merge-queue/.codex-plugin/plugin.json index d86cd3d..26bc18f 100644 --- a/adapters/codex/agent-merge-queue/.codex-plugin/plugin.json +++ b/adapters/codex/agent-merge-queue/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "deploybot", - "version": "0.2.14", + "version": "0.2.15", "description": "Coordinate exact-head pull requests through verified deployment and thread notification", "author": { "name": "DeployBot contributors" diff --git a/docs/reference.md b/docs/reference.md index d308568..1207139 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -1,7 +1,7 @@ # DeployBot reference This reference describes the CLI, MCP server, policy file, and GitHub Action in -DeployBot v0.2.14. GitHub labels and authenticated comments are the durable state; +DeployBot v0.2.15. GitHub labels and authenticated comments are the durable state; the CLI and MCP tools are two interfaces to the same operations. ## CLI diff --git a/pyproject.toml b/pyproject.toml index fcecb51..a09f40f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "deploybot-merge-queue" -version = "0.2.14" +version = "0.2.15" description = "DeployBot: a provider-neutral GitHub merge queue for coding agents" readme = "README.md" license = "MIT" diff --git a/src/agent_merge_queue/__init__.py b/src/agent_merge_queue/__init__.py index 044c7de..6679f35 100644 --- a/src/agent_merge_queue/__init__.py +++ b/src/agent_merge_queue/__init__.py @@ -1,3 +1,3 @@ """DeployBot: a provider-neutral GitHub merge queue for coding agents.""" -__version__ = "0.2.14" +__version__ = "0.2.15" diff --git a/src/agent_merge_queue/cli.py b/src/agent_merge_queue/cli.py index cc7423c..67191d5 100755 --- a/src/agent_merge_queue/cli.py +++ b/src/agent_merge_queue/cli.py @@ -179,6 +179,34 @@ def check_states(checks: Iterable[dict[str, Any]]) -> dict[str, str]: return result +def latest_exact_workflow_runs( + runs: Iterable[dict[str, Any]], + names: Iterable[str], + *, + head_sha: str, + event: str = "workflow_dispatch", +) -> dict[str, dict[str, Any]]: + configured = set(names) + latest: dict[str, dict[str, Any]] = {} + for run in runs: + name = str(run.get("name") or "") + if ( + name not in configured + or str(run.get("head_sha") or "") != head_sha + or str(run.get("event") or "") != event + ): + continue + previous = latest.get(name) + key = (str(run.get("created_at") or ""), int(run.get("id") or 0)) + previous_key = ( + str((previous or {}).get("created_at") or ""), + int((previous or {}).get("id") or 0), + ) + if previous is None or key > previous_key: + latest[name] = run + return latest + + def trusted_comment( comment: dict[str, Any], trusted_logins: str | Iterable[str], @@ -2312,6 +2340,37 @@ def command_inspect( return entry +def integration_ci_active_gate(client: GitHub, entry: QueueEntry) -> str | None: + pull = client.pull_head(entry.number) + branch = str(pull.get("branch") or "") + head_sha = str(pull.get("head_sha") or "") + if pull.get("state") != "OPEN" or not branch: + return "integration pull request is no longer open" + if head_sha != entry.head_sha: + return "integration head changed while waiting for exact CI" + configured = tuple(client.config.pipeline.ci_workflows) + latest = latest_exact_workflow_runs( + client.workflow_runs_for_branch(branch), + configured, + head_sha=head_sha, + ) + waiting: list[str] = [] + for name in configured: + run = latest.get(name) + if run is None: + waiting.append(f"{name} has not been dispatched") + continue + status = str(run.get("status") or "pending") + conclusion = str(run.get("conclusion") or "") + if status != "completed": + waiting.append(f"{name} is {status.replace('_', ' ')}") + elif conclusion != "success": + waiting.append(f"{name} {conclusion or 'failed'}") + if waiting: + return "waiting for exact integration CI: " + "; ".join(waiting) + return None + + def pipeline_status(client: GitHub) -> dict[str, Any]: queued = client.queue() queued_by_number = {entry.number: entry for entry in queued} @@ -2430,6 +2489,12 @@ def inspect( now = datetime.now(timezone.utc) alerts: list[dict[str, Any]] = [] queue_target = client.config.pipeline.ready_to_merge_target_minutes * 60 + integration_values = client.integration_pull_request_numbers() + integration_numbers = ( + {int(value) for value in integration_values} + if isinstance(integration_values, list) + else set() + ) for entry, intent, value in active_intents: timestamp = parse_time(str(intent.get("requested_at") or "")) elapsed = (now - timestamp).total_seconds() if timestamp else None @@ -2458,13 +2523,16 @@ 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" + if not entry.reasons and entry.number in integration_numbers: + active_gate = integration_ci_active_gate(client, entry) or active_gate alerts.append( { "stage": "queued-to-merge", "pull_request": entry.number, "elapsed_seconds": elapsed, "target_seconds": queue_target, - "active_gate": "; ".join(entry.reasons or []) or "merge worker", + "active_gate": active_gate, } ) if delivery["state"] not in {"verified", "testing"}: @@ -3210,8 +3278,8 @@ def settle_integration_checks( selected = [int(value) for value in values] else: selected = [int(value) for value in numbers] - results: list[dict[str, Any]] = [] configured = tuple(client.config.pipeline.ci_workflows) + targets: dict[int, dict[str, Any]] = {} for number in selected: comments = client.comments(number) integration = latest_payload( @@ -3226,9 +3294,20 @@ def settle_integration_checks( head_sha = str(pull.get("head_sha") or "") if pull.get("state") != "OPEN" or not branch or not head_sha: raise QueueError(f"integration PR #{number} is no longer open") - deadline = time.monotonic() + timeout_seconds - dispatched: list[dict[str, Any]] = [] - while True: + targets[number] = { + "branch": branch, + "dispatched": [], + "head_sha": head_sha, + "requested": set(), + } + + results: list[dict[str, Any]] = [] + pending = dict(targets) + deadline = time.monotonic() + timeout_seconds + while pending: + for number, target in list(pending.items()): + branch = str(target["branch"]) + head_sha = str(target["head_sha"]) current = client.pull_head(number) if current.get("state") != "OPEN": raise QueueError(f"integration PR #{number} is no longer open") @@ -3237,71 +3316,69 @@ def settle_integration_checks( f"integration PR #{number} changed while CI ownership was active" ) runs = client.workflow_runs_for_branch(branch) - latest: dict[str, dict[str, Any]] = {} - for run in runs: - name = str(run.get("name") or "") - if ( - name not in configured - or str(run.get("head_sha") or "") != head_sha - or str(run.get("event") or "") != "workflow_dispatch" - ): - continue - previous = latest.get(name) - key = (str(run.get("created_at") or ""), int(run.get("id") or 0)) - previous_key = ( - str((previous or {}).get("created_at") or ""), - int((previous or {}).get("id") or 0), + latest = latest_exact_workflow_runs( + runs, + configured, + head_sha=head_sha, + ) + failed = [ + name + for name, run in latest.items() + if str(run.get("status") or "") == "completed" + and str(run.get("conclusion") or "") != "success" + ] + if failed: + raise QueueError( + f"integration PR #{number} CI failed: " + ", ".join(failed) ) - if previous is None or key > previous_key: - latest[name] = run missing = [name for name in configured if name not in latest] + undispatched = [ + name for name in missing if name not in target["requested"] + ] + if undispatched: + target["dispatched"].extend( + client.dispatch_ci_workflows(ref=branch, names=undispatched) + ) + target["requested"].update(undispatched) if missing: - dispatched.extend( - client.dispatch_ci_workflows(ref=branch, names=missing) + continue + if all( + str(run.get("status") or "") == "completed" + and str(run.get("conclusion") or "") == "success" + for run in latest.values() + ): + exact_checks = check_states(client.commit_check_runs(head_sha)) + entry = client.snapshot( + number, + require_marker=False, + allow_blocked_label=True, + known_checks=exact_checks, ) - else: - failed = [ - name - for name, run in latest.items() - if str(run.get("status") or "") == "completed" - and str(run.get("conclusion") or "") != "success" - ] - if failed: - raise QueueError( - f"integration PR #{number} CI failed: " + ", ".join(failed) + if entry.state == "ready": + results.append( + { + "branch": branch, + "dispatched_ci": target["dispatched"], + "checks": exact_checks, + "head_sha": head_sha, + "pull_request": number, + "state": "ready", + } ) - if all( - str(run.get("status") or "") == "completed" - and str(run.get("conclusion") or "") == "success" - for run in latest.values() - ): - exact_checks = check_states(client.commit_check_runs(head_sha)) - entry = client.snapshot( - number, - require_marker=False, - allow_blocked_label=True, - known_checks=exact_checks, + del pending[number] + continue + if entry.state == "blocked": + raise QueueError( + f"integration PR #{number} is blocked: " + + "; ".join(entry.reasons or ["unknown gate"]) ) - if entry.state == "ready": - results.append( - { - "branch": branch, - "dispatched_ci": dispatched, - "checks": exact_checks, - "head_sha": head_sha, - "pull_request": number, - "state": "ready", - } - ) - break - if entry.state == "blocked": - raise QueueError( - f"integration PR #{number} is blocked: " - + "; ".join(entry.reasons or ["unknown gate"]) - ) - if time.monotonic() >= deadline: - raise QueueError(f"integration PR #{number} CI timed out") - time.sleep(poll_seconds) + if not pending: + break + if time.monotonic() >= deadline: + numbers = ", ".join(f"#{number}" for number in pending) + noun = "PR" if len(pending) == 1 else "PRs" + raise QueueError(f"integration {noun} {numbers} CI timed out") + time.sleep(poll_seconds) return results diff --git a/tests/test_cli.py b/tests/test_cli.py index 359643e..a8e5b5d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,6 +5,7 @@ import unittest from contextlib import redirect_stderr, redirect_stdout from datetime import datetime, timezone +from typing import Any from unittest.mock import Mock, call, patch from agent_merge_queue.cli import ( @@ -40,6 +41,7 @@ effective_queue_marker, freeze_queue, generated_only_change, + integration_ci_active_gate, latest_batch_marker, latest_marker, marker_queued_at, @@ -4180,6 +4182,210 @@ 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_every_pr_before_waiting(self) -> None: + numbers = [38, 39] + heads = {38: "a" * 40, 39: "b" * 40} + branches = { + 38: "deploybot/integration/first", + 39: "deploybot/integration/second", + } + 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), + } + ] + + def pull_head(number: int) -> dict[str, str]: + return { + "branch": branches[number], + "head_sha": heads[number], + "state": "OPEN", + } + + events: list[str] = [] + calls = {branch: 0 for branch in branches.values()} + + def workflow_runs(branch: str) -> list[dict[str, Any]]: + calls[branch] += 1 + events.append(f"query:{branch}:{calls[branch]}") + if calls[branch] == 1: + return [] + number = next(key for key, value in branches.items() if value == branch) + return [ + { + "id": number, + "name": "CI", + "head_sha": heads[number], + "event": "workflow_dispatch", + "status": "completed", + "conclusion": "success", + "created_at": "2026-06-20T00:01:00Z", + } + ] + + def dispatch(*, ref: str, names: list[str]) -> list[dict[str, Any]]: + events.append(f"dispatch:{ref}") + return [{"id": len(events), "name": names[0]}] + + client.pull_head.side_effect = pull_head + client.workflow_runs_for_branch.side_effect = workflow_runs + client.dispatch_ci_workflows.side_effect = dispatch + client.commit_check_runs.return_value = [ + { + "name": "CI", + "conclusion": "success", + "started_at": "2026-06-20T00:01:00Z", + } + ] + + def snapshot(number: int, **_kwargs: Any) -> QueueEntry: + ready = entry(number, "combined.py") + ready.head_sha = heads[number] + return ready + + client.snapshot.side_effect = snapshot + + with ( + patch("agent_merge_queue.cli.time.monotonic", return_value=0), + patch( + "agent_merge_queue.cli.time.sleep", + side_effect=lambda _seconds: events.append("sleep"), + ), + ): + result = settle_integration_checks( + client, + timeout_seconds=10, + poll_seconds=1, + numbers=numbers, + ) + + self.assertEqual( + events[:5], + [ + f"query:{branches[38]}:1", + f"dispatch:{branches[38]}", + f"query:{branches[39]}:1", + f"dispatch:{branches[39]}", + "sleep", + ], + ) + self.assertEqual( + [value["pull_request"] for value in result], + numbers, + ) + + def test_integration_ci_does_not_repeat_dispatch_before_run_appears( + 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", + } + successful = { + "id": 7, + "name": "CI", + "head_sha": head_sha, + "event": "workflow_dispatch", + "status": "completed", + "conclusion": "success", + "created_at": "2026-06-20T00:01:00Z", + } + client.workflow_runs_for_branch.side_effect = [[], [], [successful]] + client.dispatch_ci_workflows.return_value = [{"id": 7, "name": "CI"}] + client.commit_check_runs.return_value = [ + { + "name": "CI", + "conclusion": "success", + "started_at": "2026-06-20T00:01: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"), + ): + settle_integration_checks( + client, + timeout_seconds=10, + poll_seconds=0, + numbers=[number], + ) + + client.dispatch_ci_workflows.assert_called_once_with( + ref=branch, + names=["CI"], + ) + + def test_integration_ci_gate_names_the_exact_wait(self) -> None: + number = 38 + head_sha = "a" * 40 + branch = "deploybot/integration/batch" + client = Mock() + client.config = CONFIG + client.pull_head.return_value = { + "branch": branch, + "head_sha": head_sha, + "state": "OPEN", + } + client.workflow_runs_for_branch.return_value = [] + queued = entry(number, "combined.py") + queued.head_sha = head_sha + + self.assertEqual( + integration_ci_active_gate(client, queued), + "waiting for exact integration CI: CI has not been dispatched", + ) + + client.workflow_runs_for_branch.return_value = [ + { + "id": 7, + "name": "CI", + "head_sha": head_sha, + "event": "workflow_dispatch", + "status": "queued", + "conclusion": None, + "created_at": "2026-06-20T00:01:00Z", + } + ] + self.assertEqual( + integration_ci_active_gate(client, queued), + "waiting for exact integration CI: CI is queued", + ) + def test_integration_promotion_reuses_owned_exact_checks(self) -> None: number = 38 head_sha = "a" * 40 From bcc3bebe28d53d1e4ee948b2958a080627a377a4 Mon Sep 17 00:00:00 2001 From: Matthew Berman <748450+mberman84@users.noreply.github.com> Date: Sun, 21 Jun 2026 17:58:03 -0700 Subject: [PATCH 2/2] Pin DeployBot v0.2.15 runtime --- README.md | 6 +++--- adapters/claude-code/.mcp.json | 2 +- adapters/cursor/.cursor/mcp.json | 2 +- examples/github-workflow.yml | 4 ++-- tests/test_skill.py | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 0af1f63..e1c4720 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,11 @@ integration PRs, follows `main` through production, and pauses after failures. ## Install -Install the reviewed `v0.2.14` source commit directly from GitHub: +Install the reviewed `v0.2.15` source commit directly from GitHub: ```bash python3 -m pip install \ - 'deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@a5f80423f5168873d0801694baaf9d8b35d96ebd' + 'deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@1170353958fb31718977dfff465ce9e1a89b8db4' deploybot init ``` @@ -95,7 +95,7 @@ worker can dispatch deployment when GitHub suppresses the `workflow_run` event for token-dispatched CI. Pin the Action to the full reviewed release commit: ```yaml -- uses: Forward-Future/DeployBot@a5f80423f5168873d0801694baaf9d8b35d96ebd +- uses: Forward-Future/DeployBot@1170353958fb31718977dfff465ce9e1a89b8db4 ``` The Action uses GitHub's built-in workflow token. GitHub intentionally does not diff --git a/adapters/claude-code/.mcp.json b/adapters/claude-code/.mcp.json index d4fbacf..531069b 100644 --- a/adapters/claude-code/.mcp.json +++ b/adapters/claude-code/.mcp.json @@ -4,7 +4,7 @@ "command": "uvx", "args": [ "--from", - "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@a5f80423f5168873d0801694baaf9d8b35d96ebd", + "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@1170353958fb31718977dfff465ce9e1a89b8db4", "deploybot-mcp" ] } diff --git a/adapters/cursor/.cursor/mcp.json b/adapters/cursor/.cursor/mcp.json index d4fbacf..531069b 100644 --- a/adapters/cursor/.cursor/mcp.json +++ b/adapters/cursor/.cursor/mcp.json @@ -4,7 +4,7 @@ "command": "uvx", "args": [ "--from", - "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@a5f80423f5168873d0801694baaf9d8b35d96ebd", + "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@1170353958fb31718977dfff465ce9e1a89b8db4", "deploybot-mcp" ] } diff --git a/examples/github-workflow.yml b/examples/github-workflow.yml index 9b96bf7..3b8c696 100644 --- a/examples/github-workflow.yml +++ b/examples/github-workflow.yml @@ -59,5 +59,5 @@ jobs: with: ref: ${{ github.event.repository.default_branch }} persist-credentials: false - # v0.2.14 implementation; keep the full commit for privileged workflows. - - uses: Forward-Future/DeployBot@a5f80423f5168873d0801694baaf9d8b35d96ebd + # v0.2.15 implementation; keep the full commit for privileged workflows. + - uses: Forward-Future/DeployBot@1170353958fb31718977dfff465ce9e1a89b8db4 diff --git a/tests/test_skill.py b/tests/test_skill.py index d898c16..f387674 100644 --- a/tests/test_skill.py +++ b/tests/test_skill.py @@ -8,7 +8,7 @@ ROOT = Path(__file__).resolve().parents[1] CANONICAL = ROOT / "skills" / "deploybot" / "SKILL.md" -RELEASE_COMMIT = "a5f80423f5168873d0801694baaf9d8b35d96ebd" +RELEASE_COMMIT = "1170353958fb31718977dfff465ce9e1a89b8db4" CHECKOUT_COMMIT = "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0"