From 3bf238140fba3d4d5fbd2d739b9f5422e99567dd Mon Sep 17 00:00:00 2001 From: Matthew Berman <748450+mberman84@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:07:24 -0700 Subject: [PATCH 1/2] Fix stale gates and expose unbound PRs --- README.md | 7 +- .../claude-code/.claude-plugin/plugin.json | 2 +- .../claude-code/skills/deploybot/SKILL.md | 5 ++ .../skills/manage-merge-queue/SKILL.md | 4 ++ .../.codex-plugin/plugin.json | 2 +- .../skills/deploybot/SKILL.md | 6 ++ .../skills/manage-merge-queue/SKILL.md | 4 ++ docs/reference.md | 4 +- pyproject.toml | 2 +- skills/deploybot/SKILL.md | 5 ++ skills/manage-merge-queue/SKILL.md | 3 + src/agent_merge_queue/__init__.py | 2 +- src/agent_merge_queue/cli.py | 32 ++++++++- tests/test_cli.py | 65 +++++++++++++++++++ tests/test_skill.py | 2 + 15 files changed, 134 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 75d3ccc..58a8726 100644 --- a/README.md +++ b/README.md @@ -160,9 +160,12 @@ they cannot create the original per-pull-request deploy intent. `deploybot status` reports active metadata-only agent threads, pending native notifications, every PR stage, deploy requests and their exact authorized heads, queue order, queued and pre-queue intent overlaps, exact-`main` CI, deployment, -and pipeline pause state. It alerts when a deploy request exceeds the configured +pipeline pause state, and every open PR that has not been bound to its native +opening thread. It alerts when a deploy request exceeds the configured ready-to-merge target and names the current gate. It never stores prompts, -transcripts, source, or credentials. +transcripts, source, or credentials. A failed PR rollup is reconciled against +the exact commit's check runs before DeployBot creates a repair block, so a +cancelled superseded run cannot hide its replacement. `deploybot react` promotes ready intent, skips blockers, drains independent work, and creates integration PRs when configured. New batches contain at most diff --git a/adapters/claude-code/.claude-plugin/plugin.json b/adapters/claude-code/.claude-plugin/plugin.json index 30ae311..46d2fc4 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.19", + "version": "0.2.20", "description": "DeployBot: a provider-neutral GitHub merge queue for coding agents", "author": { "name": "DeployBot contributors" diff --git a/adapters/claude-code/skills/deploybot/SKILL.md b/adapters/claude-code/skills/deploybot/SKILL.md index 17b0bee..6c278a3 100644 --- a/adapters/claude-code/skills/deploybot/SKILL.md +++ b/adapters/claude-code/skills/deploybot/SKILL.md @@ -49,6 +49,11 @@ the destination for repair handoffs and the final deployment receipt. A later deploy, repair, integration, or coordinator thread must never claim ownership of an already-open PR. +Before the PR-opening thread finishes its response, call `pipeline_status` and +confirm that the exact PR appears in `pull_request_thread_owners` and not in +`unbound_pull_requests`. A missing binding means the PR-opening task is still +incomplete: publish the binding from this thread and verify it before stopping. + Require the user's exact `deploy` instruction before calling `request_deployment` or `deploybot request` for that conversation's pull request. DeployBot resolves the previously recorded PR-opening thread; never diff --git a/adapters/claude-code/skills/manage-merge-queue/SKILL.md b/adapters/claude-code/skills/manage-merge-queue/SKILL.md index 778b8ef..d377d79 100644 --- a/adapters/claude-code/skills/manage-merge-queue/SKILL.md +++ b/adapters/claude-code/skills/manage-merge-queue/SKILL.md @@ -13,6 +13,10 @@ Immediately after opening the PR, call `update_agent_thread` in `pr-review` phase with its number. That first binding is immutable and owns repair handoffs and the final deployment receipt. +Before the PR-opening response finishes, call `pipeline_status` and verify that +the exact PR is in `pull_request_thread_owners`, not +`unbound_pull_requests`. Bind it from this thread and recheck if missing. + Only the user's exact `deploy` instruction authorizes `request_deployment` for this thread's PR. DeployBot uses the recorded opening thread; a coordinator must never substitute its own ID. If review fixes change diff --git a/adapters/codex/agent-merge-queue/.codex-plugin/plugin.json b/adapters/codex/agent-merge-queue/.codex-plugin/plugin.json index 718c939..52849ec 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.19", + "version": "0.2.20", "description": "Coordinate exact-head pull requests through verified deployment and thread notification", "author": { "name": "DeployBot contributors" diff --git a/adapters/codex/agent-merge-queue/skills/deploybot/SKILL.md b/adapters/codex/agent-merge-queue/skills/deploybot/SKILL.md index 40efe5b..be16a09 100644 --- a/adapters/codex/agent-merge-queue/skills/deploybot/SKILL.md +++ b/adapters/codex/agent-merge-queue/skills/deploybot/SKILL.md @@ -44,6 +44,12 @@ update` in `pr-draft`, `pr-review`, or `ready` phase with the PR number. The first trusted binding is immutable and owns repair handoffs and the final deployment receipt. Later deploy and coordinator threads never replace it. +Before this PR-opening thread finishes its response, run `deploybot status +--json` and confirm that the exact PR appears in +`pull_request_thread_owners` and not in `unbound_pull_requests`. A missing +binding means the PR-opening task is still incomplete: publish the binding from +this thread and verify it before stopping. + Require the user's exact `deploy` instruction before running `deploybot request` for that conversation's pull request. DeployBot resolves the recorded PR-opening thread; never substitute the current coordinator's thread ID. If an diff --git a/adapters/codex/agent-merge-queue/skills/manage-merge-queue/SKILL.md b/adapters/codex/agent-merge-queue/skills/manage-merge-queue/SKILL.md index 6731559..46860b7 100644 --- a/adapters/codex/agent-merge-queue/skills/manage-merge-queue/SKILL.md +++ b/adapters/codex/agent-merge-queue/skills/manage-merge-queue/SKILL.md @@ -13,6 +13,10 @@ Immediately after opening the PR, run `deploybot thread update --provider codex --thread-id --phase pr-review --pr `. That first binding is immutable and owns repair handoffs and the final deployment receipt. +Before this PR-opening response finishes, run `deploybot status --json` and +verify that the exact PR is in `pull_request_thread_owners`, not +`unbound_pull_requests`. Bind it from this thread and recheck if missing. + Only the user's exact `deploy` instruction authorizes `deploybot request` for this thread's PR. DeployBot uses the recorded opening thread; a coordinator must never substitute its own ID. If review fixes change the diff --git a/docs/reference.md b/docs/reference.md index d4acb3a..6a6d078 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.19. GitHub labels and authenticated comments are the durable state; +DeployBot v0.2.20. GitHub labels and authenticated comments are the durable state; the CLI and MCP tools are two interfaces to the same operations. ## CLI @@ -23,7 +23,7 @@ DeployBot resolves the pull request for the current branch. | `deploybot init [--force]` | Write a safe starter policy. Existing files are preserved unless `--force` is supplied. | | `deploybot ensure-labels` | Create or refresh the configured queue, blocked, intent, pause, and registry labels. | | `deploybot doctor [--json]` | Check authentication, policy, labels, actors, checks, workflows, and branch protection without changing repository state. | -| `deploybot status [--json]` | Read active thread metadata, pending native notifications, PR stages, exact-head deploy intent, pre-queue intent overlaps, request-stage timing alerts, queue state, exact-main CI, deployment, and pipeline control state. | +| `deploybot status [--json]` | Read active thread metadata, unbound open PRs, pending native notifications, PR stages, exact-head deploy intent, pre-queue intent overlaps, request-stage timing alerts, queue state, exact-main CI, deployment, and pipeline control state. | | `deploybot plan [--json]` | Read the ordered queue, dependencies, blockers, and source-overlap groups. | | `deploybot inspect [PR] [--json]` | Evaluate one exact PR head without granting merge authority. | | `deploybot metrics [--limit N] [--json]` | Summarize p50, p95, and maximum delivery timings for recent merged PRs. The default limit is 25. | diff --git a/pyproject.toml b/pyproject.toml index fe06200..b703060 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "deploybot-merge-queue" -version = "0.2.19" +version = "0.2.20" description = "DeployBot: a provider-neutral GitHub merge queue for coding agents" readme = "README.md" license = "MIT" diff --git a/skills/deploybot/SKILL.md b/skills/deploybot/SKILL.md index 17b0bee..6c278a3 100644 --- a/skills/deploybot/SKILL.md +++ b/skills/deploybot/SKILL.md @@ -49,6 +49,11 @@ the destination for repair handoffs and the final deployment receipt. A later deploy, repair, integration, or coordinator thread must never claim ownership of an already-open PR. +Before the PR-opening thread finishes its response, call `pipeline_status` and +confirm that the exact PR appears in `pull_request_thread_owners` and not in +`unbound_pull_requests`. A missing binding means the PR-opening task is still +incomplete: publish the binding from this thread and verify it before stopping. + Require the user's exact `deploy` instruction before calling `request_deployment` or `deploybot request` for that conversation's pull request. DeployBot resolves the previously recorded PR-opening thread; never diff --git a/skills/manage-merge-queue/SKILL.md b/skills/manage-merge-queue/SKILL.md index 4eee8ea..6e06ac0 100644 --- a/skills/manage-merge-queue/SKILL.md +++ b/skills/manage-merge-queue/SKILL.md @@ -19,6 +19,9 @@ the repository's policy; never assume a particular review vendor. 6. Immediately after opening the PR, call `update_agent_thread` in `pr-draft`, `pr-review`, or `ready` phase with its number so this opening thread becomes the immutable repair and deployment-receipt destination. +7. Before finishing the PR-opening response, call `pipeline_status` and verify + that the exact PR is in `pull_request_thread_owners`, not + `unbound_pull_requests`. Bind and recheck it if missing. Do not merge merely because review is complete. diff --git a/src/agent_merge_queue/__init__.py b/src/agent_merge_queue/__init__.py index 5544306..5c5d565 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.19" +__version__ = "0.2.20" diff --git a/src/agent_merge_queue/cli.py b/src/agent_merge_queue/cli.py index ea18093..cac0e07 100755 --- a/src/agent_merge_queue/cli.py +++ b/src/agent_merge_queue/cli.py @@ -2344,9 +2344,22 @@ def snapshot( INTEGRATION_MARKER, coordinator_logins(self), ) - if integration and any( - checks.get(name) != "passed" for name in self.config.required_checks - ): + # GitHub's PR rollup can briefly retain a cancelled or failed run after + # a replacement run for the same exact head has started. Reconcile a + # reported failure with the commit check-runs endpoint before creating + # a repair block. Integration PRs keep the broader fallback because + # their controller-dispatched checks may be absent from the rollup. + rollup_has_required_failure = any( + checks.get(name) == "failed" for name in self.config.required_checks + ) + integration_needs_exact_checks = bool( + integration + and any( + checks.get(name) != "passed" + for name in self.config.required_checks + ) + ) + if rollup_has_required_failure or integration_needs_exact_checks: checks = merge_known_check_states( check_states(check_rollup + self.commit_check_runs(head_sha)), known_checks, @@ -2910,6 +2923,18 @@ def inspect( for _, value in sorted(thread_owners.items()) if value.pull_request in open_number_set ], + "unbound_pull_requests": [ + { + "pull_request": value["number"], + "pipeline_stage": stage, + "head_sha": value["head_sha"], + "title": value["title"], + "url": value["url"], + } + for stage, values in stages.items() + for value in values + if value["number"] not in thread_owners + ], "notifications": client.deployment_notifications(), "pull_requests": stages, "queue": [ @@ -2934,6 +2959,7 @@ def print_pipeline_status(value: dict[str, Any], *, json_output: bool) -> None: "threads: " f"{len(value['threads'])} active; " f"notifications: {len(value.get('notifications') or [])} pending; " + f"unbound PRs: {len(value.get('unbound_pull_requests') or [])}; " "deploy requests: " f"{sum(1 for entries in stages.values() for entry in entries if entry.get('deploy_intent'))}; " f"queue: {len(value['queue'])}; " diff --git a/tests/test_cli.py b/tests/test_cli.py index dc727f6..2fef07c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -492,6 +492,13 @@ def test_status_exposes_intent_head_overlap_and_request_delay(self) -> None: result["pull_request_thread_owners"][0]["thread_id"], "opening-thread", ) + self.assertEqual( + { + (value["pull_request"], value["pipeline_stage"]) + for value in result["unbound_pull_requests"] + }, + {(2, "blocked"), (3, "ready")}, + ) def test_overlap_mode_holds_only_ready_members_of_near_ready_components( self, @@ -1597,6 +1604,64 @@ def test_integration_snapshot_uses_exact_commit_check_fallback(self) -> None: self.assertEqual(value.checks["CI"], "passed") client.commit_check_runs.assert_called_once_with(head_sha) + def test_failed_rollup_uses_exact_head_replacement_check(self) -> None: + head_sha = "a" * 40 + client = object.__new__(GitHub) + client.config = CONFIG + client.repository = "example/repo" + client.trusted_logins = {"trusted"} + client.coordinator_logins = {"trusted"} + client.comments = Mock(return_value=[]) + client.changed_paths = Mock(return_value=([], [])) + client.commit_check_runs = Mock( + return_value=[ + { + "name": "CI", + "conclusion": "cancelled", + "started_at": "2026-06-20T00:00:00Z", + }, + { + "name": "CI", + "status": "in_progress", + "started_at": "2026-06-20T00:01:00Z", + }, + ] + ) + client._json = Mock( + return_value={ + "baseRefName": "main", + "body": "", + "headRefOid": head_sha, + "isDraft": False, + "labels": [], + "mergeStateStatus": "UNSTABLE", + "mergeable": "MERGEABLE", + "number": 39, + "state": "OPEN", + "statusCheckRollup": [ + { + "__typename": "CheckRun", + "name": "CI", + "conclusion": "CANCELLED", + "startedAt": "2026-06-20T00:00:00Z", + } + ], + "title": "Replacement check is running", + "url": "https://example.test/39", + } + ) + + value = client.snapshot( + 39, + require_marker=False, + allow_blocked_label=True, + ) + + self.assertEqual(value.state, "waiting") + self.assertEqual(value.checks["CI"], "pending") + self.assertNotIn("CI failed", value.reasons) + client.commit_check_runs.assert_called_once_with(head_sha) + def test_required_checks_accept_passing_terminal_conclusions(self) -> None: states = check_states( [ diff --git a/tests/test_skill.py b/tests/test_skill.py index 35357f6..bb7bab5 100644 --- a/tests/test_skill.py +++ b/tests/test_skill.py @@ -81,6 +81,8 @@ def test_status_guidance_is_read_only(self) -> None: self.assertIn("acknowledge_thread_deployment", skill) self.assertIn("heartbeat automation", skill) self.assertIn("notification_handoff.required_action", skill) + self.assertIn("unbound_pull_requests", skill) + self.assertIn("pull_request_thread_owners", skill) self.assertIn("human-facing release receipt", skill) self.assertIn("acknowledge silently", skill) self.assertIn("untrusted display-only", skill) From 2a189e680df4596950416e82120e0e8e185f4291 Mon Sep 17 00:00:00 2001 From: Matthew Berman <748450+mberman84@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:07:44 -0700 Subject: [PATCH 2/2] Pin DeployBot v0.2.20 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 58a8726..680fcc1 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.19` source commit directly from GitHub: +Install the reviewed `v0.2.20` source commit directly from GitHub: ```bash python3 -m pip install \ - 'deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@1b6379a258a0b6f743ce77c2d108dfd7e83d582b' + 'deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@3bf238140fba3d4d5fbd2d739b9f5422e99567dd' 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@1b6379a258a0b6f743ce77c2d108dfd7e83d582b +- uses: Forward-Future/DeployBot@3bf238140fba3d4d5fbd2d739b9f5422e99567dd ``` 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 4aed5e7..8d7dc8a 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@1b6379a258a0b6f743ce77c2d108dfd7e83d582b", + "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@3bf238140fba3d4d5fbd2d739b9f5422e99567dd", "deploybot-mcp" ] } diff --git a/adapters/cursor/.cursor/mcp.json b/adapters/cursor/.cursor/mcp.json index 4aed5e7..8d7dc8a 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@1b6379a258a0b6f743ce77c2d108dfd7e83d582b", + "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@3bf238140fba3d4d5fbd2d739b9f5422e99567dd", "deploybot-mcp" ] } diff --git a/examples/github-workflow.yml b/examples/github-workflow.yml index 26a3432..b9970d4 100644 --- a/examples/github-workflow.yml +++ b/examples/github-workflow.yml @@ -77,5 +77,5 @@ jobs: with: ref: ${{ github.event.repository.default_branch }} persist-credentials: false - # v0.2.19 implementation; keep the full commit for privileged workflows. - - uses: Forward-Future/DeployBot@1b6379a258a0b6f743ce77c2d108dfd7e83d582b + # v0.2.20 implementation; keep the full commit for privileged workflows. + - uses: Forward-Future/DeployBot@3bf238140fba3d4d5fbd2d739b9f5422e99567dd diff --git a/tests/test_skill.py b/tests/test_skill.py index bb7bab5..357ce40 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 = "1b6379a258a0b6f743ce77c2d108dfd7e83d582b" +RELEASE_COMMIT = "3bf238140fba3d4d5fbd2d739b9f5422e99567dd" CHECKOUT_COMMIT = "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0"