From 3ba0f2572af57832de77f24f685b1d12b4e3a09d Mon Sep 17 00:00:00 2001 From: Matthew Berman <748450+mberman84@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:37:35 -0700 Subject: [PATCH 1/6] Separate merge queue and release ownership --- README.md | 19 ++-- action.yml | 30 ++++-- .../claude-code/skills/deploybot/SKILL.md | 15 +++ .../skills/manage-merge-queue/SKILL.md | 8 ++ .../skills/deploybot/SKILL.md | 15 +++ .../skills/manage-merge-queue/SKILL.md | 8 ++ adapters/cursor/.cursor/rules/deploybot.mdc | 9 ++ adapters/cursor/AGENTS.md | 9 ++ docs/reference.md | 12 ++- examples/github-workflow.yml | 33 +++++-- skills/deploybot/SKILL.md | 15 +++ skills/manage-merge-queue/SKILL.md | 8 ++ src/agent_merge_queue/cli.py | 64 ++++++++++-- src/agent_merge_queue/mcp_server.py | 2 +- src/agent_merge_queue/pipeline.py | 24 +++++ tests/test_cli.py | 99 +++++++++++++++++++ tests/test_pipeline.py | 90 +++++++++++++++++ tests/test_skill.py | 46 ++++++++- 18 files changed, 469 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 3cd737c..c05bd0b 100644 --- a/README.md +++ b/README.md @@ -87,15 +87,16 @@ the user does not repeat `deploy`. No polling timer is involved. Install `examples/github-workflow.yml` on the default branch. It reacts to deploy labels, ready/synchronize events, reviews, named CI `workflow_run` completions, and completed external check suites. Keep its `workflows` list -aligned with `pipeline.ci_workflows`. A five-minute scheduled reconciliation -rereads all durable state in case GitHub concurrency coalesces the last pending -event in a burst. The privileged worker never checks out or executes -pull-request code. The Action advances releases to the configured admission -gate. In the default `merged` mode it returns after each healthy observation, -leaving completion to later release events and keeping the serialized merge -worker free. It can still dispatch deployment when GitHub suppresses the -`workflow_run` event for token-dispatched CI. Pin the Action to the full reviewed -release commit: +aligned with `pipeline.ci_workflows`. Each wake runs two independently +serialized jobs: the queue reactor can admit more ready work immediately, while +the release-only follower owns cumulative exact `main` through CI, deployment, +and verification. The follower exits immediately when no release needs work and +dispatches deployment when GitHub suppresses the `workflow_run` handoff for +token-dispatched CI, avoiding a wait for the five-minute scheduled +reconciliation. The schedule still rereads all durable state if GitHub +concurrency coalesces the last event in a burst. Neither privileged job checks +out or executes pull-request code. Pin the Action to the full reviewed release +commit: ```yaml - uses: Forward-Future/DeployBot@12c6c03aa76a553fa4068279baa29e90a30bbeb1 diff --git a/action.yml b/action.yml index 8546666..82d28ad 100644 --- a/action.yml +++ b/action.yml @@ -1,6 +1,10 @@ name: DeployBot description: Promote, batch, merge, and follow agent-authored pull requests inputs: + mode: + description: Run queue reaction or release-only follow + required: false + default: react config: description: Repository-relative merge queue policy path required: false @@ -32,16 +36,28 @@ runs: - shell: bash env: DEPLOYBOT_CONFIG: ${{ inputs.config }} + DEPLOYBOT_MODE: ${{ inputs.mode }} GH_TOKEN: ${{ inputs.token || github.token }} DEPLOYBOT_FOLLOW: ${{ inputs.follow }} DEPLOYBOT_DISPATCH_CI: ${{ inputs.dispatch_ci }} DEPLOYBOT_TIMEOUT: ${{ inputs.timeout }} run: | - args=(react --timeout "$DEPLOYBOT_TIMEOUT") - if [[ "$DEPLOYBOT_DISPATCH_CI" == "true" ]]; then - args+=(--dispatch-ci) - fi - if [[ "$DEPLOYBOT_FOLLOW" == "true" ]]; then - args+=(--follow) - fi + case "$DEPLOYBOT_MODE" in + react) + args=(react --timeout "$DEPLOYBOT_TIMEOUT") + if [[ "$DEPLOYBOT_DISPATCH_CI" == "true" ]]; then + args+=(--dispatch-ci) + fi + if [[ "$DEPLOYBOT_FOLLOW" == "true" ]]; then + args+=(--follow) + fi + ;; + follow) + args=(follow --timeout "$DEPLOYBOT_TIMEOUT" --if-needed) + ;; + *) + echo "mode must be react or follow" >&2 + exit 2 + ;; + esac deploybot --config "$DEPLOYBOT_CONFIG" "${args[@]}" diff --git a/adapters/claude-code/skills/deploybot/SKILL.md b/adapters/claude-code/skills/deploybot/SKILL.md index 707674c..5310763 100644 --- a/adapters/claude-code/skills/deploybot/SKILL.md +++ b/adapters/claude-code/skills/deploybot/SKILL.md @@ -83,6 +83,14 @@ commands. Re-read GitHub, honor a pipeline pause, freeze one exact batch, preserve first-in order unless dependencies require otherwise, and skip blocked items that do not block independent work. +Use `react_to_delivery_event` (or `deploybot react --follow --dispatch-ci`) for +queue-changing coordination. `follow_release` / `deploybot follow` is +release-only: it follows the current base revision but never promotes or drains +queued pull requests. Do not substitute it for a reaction when work is queued. +In GitHub Actions, keep queue reaction and release-only follow in separate +concurrency groups so `release_admission = "merged"` can admit more work while +one follower owns cumulative exact-main CI and deployment. + Merge independent ready pull requests back-to-back. Route source-overlap groups through `create_integration_pull_request`; when policy mode is `all`, validate the entire frozen batch through that cumulative PR. Never invent a conflict @@ -92,6 +100,13 @@ after its new exact head passes. Keep release tracking event-driven: in after a healthy merge while later events continue CI, deployment, and health tracking. Scheduled reconciliation is a fallback, not the normal promotion path. +For queue-wide requests such as "all open PRs," treat the target set as live, +not as the first snapshot. After each verified cumulative release, refresh +pipeline status, the queue plan, and the provider's open-PR list once more. +Admit newly opened authorized work and react again. Stop only when the queue, +unbound list, and provider open-PR list are all empty at the same fresh +boundary; do not leave a tight idle polling loop behind. + 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. If `main` advances while a repair is diff --git a/adapters/claude-code/skills/manage-merge-queue/SKILL.md b/adapters/claude-code/skills/manage-merge-queue/SKILL.md index 16745ec..b2572a3 100644 --- a/adapters/claude-code/skills/manage-merge-queue/SKILL.md +++ b/adapters/claude-code/skills/manage-merge-queue/SKILL.md @@ -30,6 +30,14 @@ and use `resume_pull_request` after fresh review. In `release_admission = "merged"` mode, admit independent ready work immediately after merge while later events track CI and deployment; a later failure pauses the pipeline. +Use the reaction path for queue work. `follow_release` / `deploybot follow` is +release-only and never promotes or drains queued pull requests. For an +"all open PRs" request, refresh status, the plan, and the provider's open list +after the verified release; react again for newly opened authorized work and +stop only when all three are empty at the same fresh boundary. +In GitHub Actions, keep queue reaction and release-only follow in separate +concurrency groups so release ownership never holds up merged-mode admission. + A genuine repair remains merge-ineligible, but DeployBot may temporarily hold overlapping ready work for the configured bounded repair window so concurrent merges do not repeatedly invalidate the replacement head. diff --git a/adapters/codex/agent-merge-queue/skills/deploybot/SKILL.md b/adapters/codex/agent-merge-queue/skills/deploybot/SKILL.md index 055a843..7443ff7 100644 --- a/adapters/codex/agent-merge-queue/skills/deploybot/SKILL.md +++ b/adapters/codex/agent-merge-queue/skills/deploybot/SKILL.md @@ -77,6 +77,14 @@ Only the designated coordinator may run `deploybot promote`, `deploybot react`, pause, freeze one exact batch, preserve first-in order unless dependencies require otherwise, and skip blocked items that do not block independent work. +Use `deploybot react --follow --dispatch-ci` for queue-changing coordination. +`deploybot follow` is release-only: it follows the current `main` revision but +never promotes or drains queued pull requests. Do not substitute it for +`react` when work is queued. +In GitHub Actions, keep queue reaction and release-only follow in separate +concurrency groups so `release_admission = "merged"` can admit more work while +one follower owns cumulative exact-main CI and deployment. + Merge independent ready pull requests back-to-back. Route source-overlap groups through `deploybot integrate`; when policy mode is `all`, validate the entire frozen batch through that cumulative PR. Never invent a conflict resolution. @@ -86,6 +94,13 @@ its new exact head passes. Keep release tracking event-driven: in after a healthy merge while later events continue CI, deployment, and health tracking. Scheduled reconciliation is a fallback, not the normal promotion path. +For queue-wide requests such as "all open PRs," treat the target set as live, +not as the first snapshot. After each verified cumulative release, refresh +`deploybot status --json`, `deploybot plan --json`, and the provider's open-PR +list once more. Admit newly opened authorized work and react again. Stop only +when the queue, unbound list, and provider open-PR list are all empty at the +same fresh boundary; do not leave a tight idle polling loop behind. + 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. If `main` advances while a repair is 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 48b56b5..0f9ba84 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 @@ -31,6 +31,14 @@ after fresh review. In `release_admission = "merged"` mode, admit independent ready work immediately after merge while later events track CI and deployment; a later failure pauses the pipeline. +Use `deploybot react --follow --dispatch-ci` for queue work. `deploybot follow` +is release-only and never promotes or drains queued pull requests. For an +"all open PRs" request, refresh status, the plan, and the provider's open list +after the verified release; react again for newly opened authorized work and +stop only when all three are empty at the same fresh boundary. +In GitHub Actions, keep queue reaction and release-only follow in separate +concurrency groups so release ownership never holds up merged-mode admission. + A genuine repair remains merge-ineligible, but DeployBot may temporarily hold overlapping ready work for the configured bounded repair window so concurrent merges do not repeatedly invalidate the replacement head. diff --git a/adapters/cursor/.cursor/rules/deploybot.mdc b/adapters/cursor/.cursor/rules/deploybot.mdc index b0e6174..a018dbd 100644 --- a/adapters/cursor/.cursor/rules/deploybot.mdc +++ b/adapters/cursor/.cursor/rules/deploybot.mdc @@ -15,6 +15,15 @@ the stable Cursor thread ID, never prompts or transcripts. Refresh intent only after replacement-head review. Only the coordinator may react, integrate, drain, follow, pause, or resume repaired work. +Use the reaction path for queue-changing coordination. `follow_release` is +release-only: it observes the current base revision and never promotes or +drains queued pull requests. For a queue-wide request such as "all open PRs," +refresh `pipeline_status`, `queue_plan`, and the provider's open pull-request +list after the verified release. Stop only when the queue, unbound PRs, and +provider list are all empty at that same fresh boundary. +In GitHub Actions, keep queue reaction and release-only follow in separate +concurrency groups so release ownership never holds up merged-mode admission. + Honor `pipeline.release_admission`. In `merged` mode, admit the next independent ready PR immediately after a healthy merge while release events continue CI, deployment, and health tracking. A later failure pauses future merges normally. diff --git a/adapters/cursor/AGENTS.md b/adapters/cursor/AGENTS.md index a3e98dc..7b1cc2d 100644 --- a/adapters/cursor/AGENTS.md +++ b/adapters/cursor/AGENTS.md @@ -31,6 +31,15 @@ fresh review, and follow cumulative `main` through verified deployment. When `release_admission = "merged"`, admit independent ready work immediately after merge while release events continue asynchronously; later failures still pause. +Use the reaction path for queue-changing coordination. `follow_release` is +release-only: it observes the current base revision and never promotes or +drains queued pull requests. For a queue-wide request such as "all open PRs," +refresh `pipeline_status`, `queue_plan`, and the provider's open pull-request +list after the verified release. Stop only when the queue, unbound PRs, and +provider list are all empty at that same fresh boundary. +In GitHub Actions, keep queue reaction and release-only follow in separate +concurrency groups so release ownership never holds up merged-mode admission. + For each verified `thread_notifications` entry, post its message back to the native PR-opening thread and only then call `acknowledge_thread_deployment`. Leave failed notifications `pending` for a later retry, and pass the matching diff --git a/docs/reference.md b/docs/reference.md index 77c739a..4129813 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -59,7 +59,7 @@ never substitute its own thread ID. | `deploybot drain [--json]` | Freeze as needed and merge independent ready entries in the active batch. | | `deploybot react [--follow] [--dispatch-ci] [--timeout SECONDS]` | Run the event-driven promotion, batching, merge, and optional release-follow flow. The timeout defaults to 1800 seconds. | | `deploybot integrate [--all]` | Scaffold a cumulative integration PR for overlap groups, or the whole frozen batch with `--all`. | -| `deploybot follow [--timeout SECONDS] [--poll SECONDS] [--json]` | Follow the newest exact base-branch head through CI, deployment, and HTTP verification. Defaults: 1800-second timeout and 10-second poll. | +| `deploybot follow [--if-needed] [--timeout SECONDS] [--poll SECONDS] [--json]` | Follow the newest exact base-branch head through CI, deployment, and HTTP verification. `--if-needed` exits immediately when no unfinished release exists. This command never promotes or drains queued PRs; when queue work remains its output includes a `queue_handoff` naming `deploybot react --follow --dispatch-ci`. Defaults: 1800-second timeout and 10-second poll. | | `deploybot pause --reason TEXT` | Pause merging after a delivery failure. | | `deploybot unpause --sha SHA --control-id ID [--follow] [--dispatch-ci] [--timeout SECONDS] [--no-wake]` | Conditionally resume the matching failed release after fresh status revalidation and verified repair; a running record can clear only that unique pause, so changed control or advanced main fails closed. The recovery records the exact SHA and reason it resumed so a stale reread of the same failure cannot re-pause it. After recording the recovery, DeployBot immediately reacts so the elected repair merges without waiting for the next event or the five-minute sweep; `--no-wake` records the recovery only, and `--follow`/`--dispatch-ci`/`--timeout` shape that wake-up reaction. The original deploy instruction remains sufficient unless rollback, bypass, or mismatched recovery expands authority. | | `deploybot claim-release-repair --provider CLIENT --thread-id ID [--thread-url URL] [--sha SHA]` | Atomically claim the owner-encoded deterministic repair branch for the current failed exact-main release. Other threads recover the same owner from the ref instead of creating duplicate repair PRs. | @@ -200,19 +200,23 @@ Provider fields are: ## GitHub Action -The composite Action runs `deploybot react` from the checked-out default branch. +The composite Action runs either queue reaction or release-only follow from the +checked-out default branch. The example workflow uses separate job concurrency +groups so `release_admission = "merged"` can keep admitting ready work while one +cumulative release follower owns CI and deployment. | Input | Default | Purpose | | --- | --- | --- | +| `mode` | `react` | Run `react` for queue work or `follow` for release-only ownership. Follow mode uses `--if-needed` and always continues through verified deployment. | | `config` | `.mergequeue.toml` | Repository-relative policy path. | -| `follow` | `"true"` | Add `--follow` to the event worker. | +| `follow` | `"true"` | In `react` mode, add `--follow` to advance only to the configured merge-admission gate. The split example workflow sets this to `"false"` because its independent release job owns completion. | | `dispatch_ci` | `"true"` | Dispatch configured CI after a merge made with `github.token`. | | `timeout` | `"1800"` | Release-follow timeout in seconds. | | `token` | `""` | GitHub App installation token used to author integration PRs so normal PR checks and events run; empty falls back to `github.token`. | The workflow needs `contents: write`, `pull-requests: write`, `checks: read`, `issues: write`, and `actions: write`. Use the event filters, concurrency group, -and scheduled full-state reconciliation in +separate queue/release concurrency groups, and scheduled full-state reconciliation in [`examples/github-workflow.yml`](../examples/github-workflow.yml), pin the Action to a reviewed full commit SHA, and keep its `workflow_run.workflows` list aligned with `pipeline.ci_workflows`. diff --git a/examples/github-workflow.yml b/examples/github-workflow.yml index 062ee42..0876ad3 100644 --- a/examples/github-workflow.yml +++ b/examples/github-workflow.yml @@ -24,10 +24,6 @@ permissions: issues: write actions: write # required for post-merge workflow_dispatch with github.token -concurrency: - group: deploybot-${{ github.repository }} - cancel-in-progress: false - jobs: react: # If CI intentionally hands off from a protected release branch, replace @@ -72,6 +68,9 @@ jobs: ) ) runs-on: ubuntu-latest + concurrency: + group: deploybot-queue-${{ github.repository }} + cancel-in-progress: false steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 with: @@ -80,8 +79,26 @@ jobs: # v0.2.25 implementation; keep the full commit for privileged workflows. - uses: Forward-Future/DeployBot@12c6c03aa76a553fa4068279baa29e90a30bbeb1 with: - # PR and review events reconcile immediately. Release-owner events - # advance to the configured admission gate; "merged" observations - # return quickly, while the schedule remains a fallback reconciliation. - follow: ${{ github.event_name == 'workflow_run' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} + # Keep queue admission independent from release ownership so merged + # mode can continue admitting ready work while CI and Deploy run. + follow: "false" timeout: ${{ (github.event_name == 'workflow_run' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && '2400' || '600' }} + + release: + needs: react + if: ${{ needs.react.result == 'success' }} + runs-on: ubuntu-latest + concurrency: + group: deploybot-release-${{ github.repository }} + cancel-in-progress: false + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + # Release-only ownership never promotes or drains pull requests. It + # exits immediately when no exact-main release needs work. + - uses: Forward-Future/DeployBot@12c6c03aa76a553fa4068279baa29e90a30bbeb1 + with: + mode: follow + timeout: "2400" diff --git a/skills/deploybot/SKILL.md b/skills/deploybot/SKILL.md index 707674c..5310763 100644 --- a/skills/deploybot/SKILL.md +++ b/skills/deploybot/SKILL.md @@ -83,6 +83,14 @@ commands. Re-read GitHub, honor a pipeline pause, freeze one exact batch, preserve first-in order unless dependencies require otherwise, and skip blocked items that do not block independent work. +Use `react_to_delivery_event` (or `deploybot react --follow --dispatch-ci`) for +queue-changing coordination. `follow_release` / `deploybot follow` is +release-only: it follows the current base revision but never promotes or drains +queued pull requests. Do not substitute it for a reaction when work is queued. +In GitHub Actions, keep queue reaction and release-only follow in separate +concurrency groups so `release_admission = "merged"` can admit more work while +one follower owns cumulative exact-main CI and deployment. + Merge independent ready pull requests back-to-back. Route source-overlap groups through `create_integration_pull_request`; when policy mode is `all`, validate the entire frozen batch through that cumulative PR. Never invent a conflict @@ -92,6 +100,13 @@ after its new exact head passes. Keep release tracking event-driven: in after a healthy merge while later events continue CI, deployment, and health tracking. Scheduled reconciliation is a fallback, not the normal promotion path. +For queue-wide requests such as "all open PRs," treat the target set as live, +not as the first snapshot. After each verified cumulative release, refresh +pipeline status, the queue plan, and the provider's open-PR list once more. +Admit newly opened authorized work and react again. Stop only when the queue, +unbound list, and provider open-PR list are all empty at the same fresh +boundary; do not leave a tight idle polling loop behind. + 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. If `main` advances while a repair is diff --git a/skills/manage-merge-queue/SKILL.md b/skills/manage-merge-queue/SKILL.md index bea7814..4932563 100644 --- a/skills/manage-merge-queue/SKILL.md +++ b/skills/manage-merge-queue/SKILL.md @@ -44,6 +44,14 @@ Use `pipeline_status`, then `react_to_delivery_event` or the narrower queue tools. Preserve first-in order unless dependencies require another order. Merge independent ready PRs back-to-back without rebasing merely because `main` moved. +Use the reaction path for queue work. `follow_release` / `deploybot follow` is +release-only and never promotes or drains queued pull requests. For an +"all open PRs" request, refresh status, the plan, and the provider's open list +after the verified release; react again for newly opened authorized work and +stop only when all three are empty at the same fresh boundary. +In GitHub Actions, keep queue reaction and release-only follow in separate +concurrency groups so release ownership never holds up merged-mode admission. + Skip blocked or waiting PRs so they do not stop independent work. A blocker creates a structured repair handoff to the source thread. After the repair has fresh checks and review, call `resume_pull_request` once. If policy requests an diff --git a/src/agent_merge_queue/cli.py b/src/agent_merge_queue/cli.py index 6ae8219..7886a4c 100755 --- a/src/agent_merge_queue/cli.py +++ b/src/agent_merge_queue/cli.py @@ -5412,7 +5412,20 @@ def command_follow( json_output: bool, emit: bool = True, admit_gate: str = "verified", + if_needed: bool = False, ) -> dict[str, Any]: + if if_needed and not release_follow_needed(client): + result = { + "state": "idle", + "main_sha": client.base_sha(), + "reason": "no unfinished exact-main release needs a follower", + } + if emit: + if json_output: + print(json.dumps(result, indent=2, sort_keys=True)) + else: + print(f"release idle on {result['main_sha']}") + return result try: result = follow_release( client, @@ -5580,24 +5593,57 @@ def command_follow( ) if not emit: return result + queued_numbers = client.queued_numbers() + if queued_numbers: + result = { + **result, + "queue_handoff": { + "state": "queued-work-remains", + "pull_requests": queued_numbers, + "reason": ( + "follow is release-only and does not promote or drain queued " + "pull requests" + ), + "required_command": "deploybot react --follow --dispatch-ci", + }, + } if json_output: print(json.dumps(result, indent=2, sort_keys=True)) else: print(f"release {result['state']} on {result['main_sha']}") + if queued_numbers: + pulls = ", ".join(f"#{number}" for number in queued_numbers) + print( + f"queued pull requests remain ({pulls}); run " + "deploybot react --follow --dispatch-ci" + ) return result def release_follow_needed(client: GitHub) -> bool: + main_sha = client.base_sha() current = release_state( - main_sha=client.base_sha(), + main_sha=main_sha, runs=client.workflow_runs(), config=client.config.pipeline, ) if current["state"] == "testing": - # A repository with no current main CI has no release to follow. An - # active or queued run is returned as latest_ci and should keep its - # release owner. - return current.get("latest_ci") is not None + # CI registration can lag immediately after a merge. Preserve the + # release owner when a durable merged thread reaches current main, + # even before the workflow run appears in GitHub's list endpoint. + if current.get("latest_ci") is not None: + return True + if client.verified_main_sha() == main_sha: + return False + records = client.thread_records(include_terminal=True) + if not isinstance(records, list): + records = [] + return any( + record.get("phase") == "merged" + and bool(record.get("merge_sha")) + and client.is_ancestor(str(record["merge_sha"]), main_sha) + for record in records + ) if current["state"] == "verified": # Verification can finish just before the original worker is replaced. # Revisit it while a merge still needs an outbox entry. A pending @@ -6597,6 +6643,11 @@ def build_parser() -> argparse.ArgumentParser: ) follow.add_argument("--timeout", type=int, default=1800) follow.add_argument("--poll", type=int, default=10) + follow.add_argument( + "--if-needed", + action="store_true", + help="return immediately when no unfinished exact-main release exists", + ) follow.add_argument("--json", action="store_true", dest="json_output") pause = subparsers.add_parser( "pause", help="pause merging after a delivery failure" @@ -6746,7 +6797,8 @@ def main(argv: list[str] | None = None) -> int: timeout_seconds=arguments.timeout, poll_seconds=arguments.poll, json_output=arguments.json_output, - admit_gate=client.config.pipeline.release_admission, + admit_gate="verified", + if_needed=arguments.if_needed, ) elif arguments.command == "pause": command_control(client, state="paused", reason=arguments.reason) diff --git a/src/agent_merge_queue/mcp_server.py b/src/agent_merge_queue/mcp_server.py index 9b39e0a..4188ee0 100644 --- a/src/agent_merge_queue/mcp_server.py +++ b/src/agent_merge_queue/mcp_server.py @@ -175,7 +175,7 @@ def follow_release( repository: str | None = None, config: str | None = None, ) -> str: - """Advance the newest exact-main release to its configured admission gate.""" + """Advance the newest exact-main release through verified deployment.""" return _run( "follow", "--timeout", diff --git a/src/agent_merge_queue/pipeline.py b/src/agent_merge_queue/pipeline.py index 4bc713b..f824f32 100644 --- a/src/agent_merge_queue/pipeline.py +++ b/src/agent_merge_queue/pipeline.py @@ -59,6 +59,30 @@ def release_state( *, main_sha: str, runs: list[dict[str, Any]], config: PipelineConfig ) -> dict[str, Any]: ci = latest_run(runs, config.ci_workflows, main_sha) + substantive_ci = latest_run( + [ + run + for run in runs + if not ( + str(run.get("status") or "") == "completed" + and str(run.get("conclusion") or "") == "cancelled" + ) + ], + config.ci_workflows, + main_sha, + ) + # CI triggered by a merge and CI explicitly dispatched by DeployBot can + # race on the same commit. Workflow concurrency may cancel whichever run + # GitHub created last, but that duplicate cancellation cannot invalidate a + # completed substantive run for the identical source tree. Preserve the + # newest non-cancelled result so real later failures and active reruns stay + # authoritative. + if ( + substantive_ci is not None + and str((ci or {}).get("status") or "") == "completed" + and str((ci or {}).get("conclusion") or "") == "cancelled" + ): + ci = substantive_ci # A workflow_run deployment commonly starts for pull-request CI and then # skips itself because the upstream run was not exact main. GitHub reports # the downstream run against the default-branch SHA, so it can otherwise diff --git a/tests/test_cli.py b/tests/test_cli.py index 1491fe0..79197d3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -201,6 +201,62 @@ def test_follow_binds_pause_to_observed_failed_main(self) -> None: "paused", f"ci-failed on {sha}", main_sha=sha ) + def test_direct_follow_reports_queued_work_handoff(self) -> None: + sha = "a" * 40 + client = Mock() + client.config = CONFIG + client.queued_numbers.return_value = [42, 43] + release = { + "state": "testing", + "main_sha": sha, + "latest_ci": None, + "latest_deploy": None, + "verifications": [], + } + + output = io.StringIO() + with ( + patch("agent_merge_queue.cli.follow_release", return_value=release), + redirect_stdout(output), + ): + result = command_follow( + client, + timeout_seconds=10, + poll_seconds=1, + json_output=True, + ) + + handoff = result["queue_handoff"] + self.assertEqual(handoff["pull_requests"], [42, 43]) + self.assertEqual( + handoff["required_command"], + "deploybot react --follow --dispatch-ci", + ) + self.assertIn("release-only", handoff["reason"]) + self.assertEqual(json.loads(output.getvalue()), result) + + def test_if_needed_follow_returns_immediately_without_release_work(self) -> None: + sha = "a" * 40 + client = Mock() + client.base_sha.return_value = sha + + with ( + patch("agent_merge_queue.cli.release_follow_needed", return_value=False), + patch("agent_merge_queue.cli.follow_release") as follow, + ): + result = command_follow( + client, + timeout_seconds=10, + poll_seconds=1, + json_output=False, + emit=False, + if_needed=True, + ) + + self.assertEqual(result["state"], "idle") + self.assertEqual(result["main_sha"], sha) + follow.assert_not_called() + def test_pull_release_details_reads_human_facing_metadata(self) -> None: client = object.__new__(GitHub) client.repository = "example/repo" @@ -309,6 +365,33 @@ def test_status_is_a_read_only_pipeline_view(self) -> None: self.assertEqual(result, 0) print_status.assert_called_once_with(status, json_output=True) + def test_direct_follow_owns_verified_release_independent_of_admission(self) -> None: + config = parse_config( + { + "queue": { + "required_checks": ["CI"], + "trusted_actors": ["trusted"], + }, + "pipeline": {"release_admission": "merged"}, + } + ) + with ( + patch("agent_merge_queue.cli.load_config", return_value=config), + patch("agent_merge_queue.cli.GitHub") as github, + patch("agent_merge_queue.cli.command_follow") as follow, + ): + result = main(["follow", "--if-needed", "--timeout", "10"]) + + self.assertEqual(result, 0) + follow.assert_called_once_with( + github.return_value, + timeout_seconds=10, + poll_seconds=10, + json_output=False, + admit_gate="verified", + if_needed=True, + ) + def test_dependency_directive_is_configurable(self) -> None: body = "Queue-after: #12, #14\nBlocked by #99" self.assertEqual(structured_dependencies(body, "Queue-after"), [12, 14]) @@ -7987,6 +8070,22 @@ def test_verified_release_does_not_spin_for_source_owned_receipt(self) -> None: ): self.assertFalse(release_follow_needed(client)) + def test_release_follower_survives_ci_registration_lag_after_merge(self) -> None: + sha = "a" * 40 + client = Mock() + client.config = CONFIG + client.base_sha.return_value = sha + client.workflow_runs.return_value = [] + client.verified_main_sha.return_value = "f" * 40 + client.thread_records.return_value = [ + {"phase": "merged", "merge_sha": sha, "pull_request": 42} + ] + client.is_ancestor.return_value = True + + self.assertTrue(release_follow_needed(client)) + + client.is_ancestor.assert_called_once_with(sha, sha) + def test_verified_release_retries_receipt_when_webhook_is_ready(self) -> None: config = parse_config( { diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 9d965aa..5e09915 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -115,6 +115,96 @@ def test_successful_deploy_survives_later_cancelled_duplicate(self) -> None: self.assertEqual(value["state"], "verified") self.assertEqual(value["latest_deploy"]["id"], 2) + def test_successful_ci_survives_later_cancelled_duplicate(self) -> None: + sha = "a" * 40 + runs = [ + { + "id": 1, + "name": "CI", + "head_sha": sha, + "status": "completed", + "conclusion": "success", + "event": "workflow_dispatch", + "created_at": "2026-06-20T00:00:00Z", + "updated_at": "2026-06-20T00:01:00Z", + }, + { + "id": 2, + "name": "CI", + "head_sha": sha, + "status": "completed", + "conclusion": "cancelled", + "event": "push", + "created_at": "2026-06-20T00:00:01Z", + "updated_at": "2026-06-20T00:00:02Z", + }, + ] + + value = release_state(main_sha=sha, runs=runs, config=CONFIG.pipeline) + + self.assertEqual(value["state"], "awaiting-deploy") + self.assertEqual(value["latest_ci"]["id"], 1) + + def test_later_failed_ci_is_not_hidden_by_older_success(self) -> None: + sha = "a" * 40 + runs = [ + { + "id": 1, + "name": "CI", + "head_sha": sha, + "status": "completed", + "conclusion": "success", + "created_at": "2026-06-20T00:00:00Z", + }, + { + "id": 2, + "name": "CI", + "head_sha": sha, + "status": "completed", + "conclusion": "failure", + "created_at": "2026-06-20T00:01:00Z", + }, + ] + + value = release_state(main_sha=sha, runs=runs, config=CONFIG.pipeline) + + self.assertEqual(value["state"], "ci-failed") + self.assertEqual(value["latest_ci"]["id"], 2) + + def test_cancelled_duplicate_does_not_hide_intervening_ci_failure(self) -> None: + sha = "a" * 40 + runs = [ + { + "id": 1, + "name": "CI", + "head_sha": sha, + "status": "completed", + "conclusion": "success", + "created_at": "2026-06-20T00:00:00Z", + }, + { + "id": 2, + "name": "CI", + "head_sha": sha, + "status": "completed", + "conclusion": "failure", + "created_at": "2026-06-20T00:01:00Z", + }, + { + "id": 3, + "name": "CI", + "head_sha": sha, + "status": "completed", + "conclusion": "cancelled", + "created_at": "2026-06-20T00:02:00Z", + }, + ] + + value = release_state(main_sha=sha, runs=runs, config=CONFIG.pipeline) + + self.assertEqual(value["state"], "ci-failed") + self.assertEqual(value["latest_ci"]["id"], 2) + def test_later_failed_deploy_is_not_hidden_by_older_success(self) -> None: sha = "a" * 40 runs = [ diff --git a/tests/test_skill.py b/tests/test_skill.py index f573fb6..2b88e26 100644 --- a/tests/test_skill.py +++ b/tests/test_skill.py @@ -129,6 +129,42 @@ def test_every_adapter_revalidates_before_unpause_handoff(self) -> None: self.assertIn("original", text.lower()) self.assertIn("unpause", text) + def test_every_operator_surface_distinguishes_follow_from_queue_work(self) -> None: + paths = [ + ROOT / "skills" / "deploybot" / "SKILL.md", + ROOT / "skills" / "manage-merge-queue" / "SKILL.md", + ROOT / "adapters" / "claude-code" / "skills" / "deploybot" / "SKILL.md", + ROOT + / "adapters" + / "claude-code" + / "skills" + / "manage-merge-queue" + / "SKILL.md", + ROOT + / "adapters" + / "codex" + / "agent-merge-queue" + / "skills" + / "deploybot" + / "SKILL.md", + ROOT + / "adapters" + / "codex" + / "agent-merge-queue" + / "skills" + / "manage-merge-queue" + / "SKILL.md", + ROOT / "adapters" / "cursor" / ".cursor" / "rules" / "deploybot.mdc", + ROOT / "adapters" / "cursor" / "AGENTS.md", + ] + for path in paths: + text = " ".join(path.read_text(encoding="utf-8").split()) + with self.subTest(path=path): + self.assertIn("release-only", text) + self.assertIn("never promotes or drains", text) + self.assertIn("all open PRs", text) + self.assertIn("same fresh boundary", text) + def test_cursor_adapter_exposes_status_workflow(self) -> None: rule = ( ROOT / "adapters" / "cursor" / ".cursor" / "rules" / "deploybot.mdc" @@ -175,7 +211,11 @@ def test_github_workflow_wakes_after_named_ci_finishes(self) -> None: self.assertIn("github.event.check_suite.app.slug != 'github-actions'", workflow) self.assertIn("github.event.check_suite.pull_requests[0].base.ref", workflow) self.assertIn("persist-credentials: false", workflow) - self.assertIn("follow: ${{ github.event_name == 'workflow_run'", workflow) + self.assertIn('follow: "false"', workflow) + self.assertIn("mode: follow", workflow) + self.assertIn("deploybot-queue-${{ github.repository }}", workflow) + self.assertIn("deploybot-release-${{ github.repository }}", workflow) + self.assertIn("Release-only ownership never promotes or drains", workflow) self.assertIn("&& '2400' || '600'", workflow) def test_workflows_pin_current_checkout_runtime(self) -> None: @@ -206,11 +246,13 @@ def test_action_dispatches_ci_after_builtin_token_merge(self) -> None: self.assertIn("actions: write", example) self.assertIn("workflow_dispatch:", workflow) - def test_action_follows_release_when_workflow_run_is_suppressed(self) -> None: + def test_action_has_independent_release_only_mode(self) -> None: action = (ROOT / "action.yml").read_text(encoding="utf-8") follow_input = action.split(" follow:\n", 1)[1].split(" dispatch_ci:\n", 1)[0] self.assertIn('default: "true"', follow_input) self.assertIn("args+=(--follow)", action) + self.assertIn('default: react', action) + self.assertIn('args=(follow --timeout "$DEPLOYBOT_TIMEOUT" --if-needed)', action) def test_clients_pin_the_immutable_status_release(self) -> None: paths = [ From 6c90c3f605269705593c4327def430c834952d01 Mon Sep 17 00:00:00 2001 From: Matthew Berman <748450+mberman84@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:37:52 -0700 Subject: [PATCH 2/6] Pin split coordinator workflow --- README.md | 4 ++-- adapters/claude-code/.mcp.json | 2 +- adapters/cursor/.cursor/mcp.json | 2 +- examples/github-workflow.yml | 7 ++++--- tests/test_skill.py | 2 +- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index c05bd0b..88c34c3 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Install the reviewed `v0.2.25` source commit directly from GitHub: ```bash python3 -m pip install \ - 'deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@12c6c03aa76a553fa4068279baa29e90a30bbeb1' + 'deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@3ba0f2572af57832de77f24f685b1d12b4e3a09d' deploybot init ``` @@ -99,7 +99,7 @@ out or executes pull-request code. Pin the Action to the full reviewed release commit: ```yaml -- uses: Forward-Future/DeployBot@12c6c03aa76a553fa4068279baa29e90a30bbeb1 +- uses: Forward-Future/DeployBot@3ba0f2572af57832de77f24f685b1d12b4e3a09d ``` 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 375d6e7..1cd9cf0 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@12c6c03aa76a553fa4068279baa29e90a30bbeb1", + "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@3ba0f2572af57832de77f24f685b1d12b4e3a09d", "deploybot-mcp" ] } diff --git a/adapters/cursor/.cursor/mcp.json b/adapters/cursor/.cursor/mcp.json index 375d6e7..1cd9cf0 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@12c6c03aa76a553fa4068279baa29e90a30bbeb1", + "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@3ba0f2572af57832de77f24f685b1d12b4e3a09d", "deploybot-mcp" ] } diff --git a/examples/github-workflow.yml b/examples/github-workflow.yml index 0876ad3..53841ad 100644 --- a/examples/github-workflow.yml +++ b/examples/github-workflow.yml @@ -76,8 +76,9 @@ jobs: with: ref: ${{ github.event.repository.default_branch }} persist-credentials: false - # v0.2.25 implementation; keep the full commit for privileged workflows. - - uses: Forward-Future/DeployBot@12c6c03aa76a553fa4068279baa29e90a30bbeb1 + # Reviewed split-coordinator implementation; keep the full commit for + # privileged workflows. + - uses: Forward-Future/DeployBot@3ba0f2572af57832de77f24f685b1d12b4e3a09d with: # Keep queue admission independent from release ownership so merged # mode can continue admitting ready work while CI and Deploy run. @@ -98,7 +99,7 @@ jobs: persist-credentials: false # Release-only ownership never promotes or drains pull requests. It # exits immediately when no exact-main release needs work. - - uses: Forward-Future/DeployBot@12c6c03aa76a553fa4068279baa29e90a30bbeb1 + - uses: Forward-Future/DeployBot@3ba0f2572af57832de77f24f685b1d12b4e3a09d with: mode: follow timeout: "2400" diff --git a/tests/test_skill.py b/tests/test_skill.py index 2b88e26..55adf95 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 = "12c6c03aa76a553fa4068279baa29e90a30bbeb1" +RELEASE_COMMIT = "3ba0f2572af57832de77f24f685b1d12b4e3a09d" CHECKOUT_COMMIT = "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0" From e9ceb594a9a108b3f5ea4e82f164aed9d42b2501 Mon Sep 17 00:00:00 2001 From: Matthew Berman <748450+mberman84@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:38:47 -0700 Subject: [PATCH 3/6] Preserve queue handoffs for idle followers --- src/agent_merge_queue/cli.py | 70 ++++++++++++++++++++---------------- tests/test_cli.py | 24 +++++++++++++ 2 files changed, 63 insertions(+), 31 deletions(-) diff --git a/src/agent_merge_queue/cli.py b/src/agent_merge_queue/cli.py index 7886a4c..29ff6fc 100755 --- a/src/agent_merge_queue/cli.py +++ b/src/agent_merge_queue/cli.py @@ -5404,6 +5404,39 @@ def record_pending_deployment_notification( return record +def emit_follow_result( + client: GitHub, + result: dict[str, Any], + *, + json_output: bool, +) -> dict[str, Any]: + queued_numbers = client.queued_numbers() + if queued_numbers: + result = { + **result, + "queue_handoff": { + "state": "queued-work-remains", + "pull_requests": queued_numbers, + "reason": ( + "follow is release-only and does not promote or drain queued " + "pull requests" + ), + "required_command": "deploybot react --follow --dispatch-ci", + }, + } + if json_output: + print(json.dumps(result, indent=2, sort_keys=True)) + else: + print(f"release {result['state']} on {result['main_sha']}") + if queued_numbers: + pulls = ", ".join(f"#{number}" for number in queued_numbers) + print( + f"queued pull requests remain ({pulls}); run " + "deploybot react --follow --dispatch-ci" + ) + return result + + def command_follow( client: GitHub, *, @@ -5420,12 +5453,11 @@ def command_follow( "main_sha": client.base_sha(), "reason": "no unfinished exact-main release needs a follower", } - if emit: - if json_output: - print(json.dumps(result, indent=2, sort_keys=True)) - else: - print(f"release idle on {result['main_sha']}") - return result + return ( + emit_follow_result(client, result, json_output=json_output) + if emit + else result + ) try: result = follow_release( client, @@ -5593,31 +5625,7 @@ def command_follow( ) if not emit: return result - queued_numbers = client.queued_numbers() - if queued_numbers: - result = { - **result, - "queue_handoff": { - "state": "queued-work-remains", - "pull_requests": queued_numbers, - "reason": ( - "follow is release-only and does not promote or drain queued " - "pull requests" - ), - "required_command": "deploybot react --follow --dispatch-ci", - }, - } - if json_output: - print(json.dumps(result, indent=2, sort_keys=True)) - else: - print(f"release {result['state']} on {result['main_sha']}") - if queued_numbers: - pulls = ", ".join(f"#{number}" for number in queued_numbers) - print( - f"queued pull requests remain ({pulls}); run " - "deploybot react --follow --dispatch-ci" - ) - return result + return emit_follow_result(client, result, json_output=json_output) def release_follow_needed(client: GitHub) -> bool: diff --git a/tests/test_cli.py b/tests/test_cli.py index 79197d3..8dabdec 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -239,6 +239,7 @@ def test_if_needed_follow_returns_immediately_without_release_work(self) -> None sha = "a" * 40 client = Mock() client.base_sha.return_value = sha + client.queued_numbers.return_value = [] with ( patch("agent_merge_queue.cli.release_follow_needed", return_value=False), @@ -257,6 +258,29 @@ def test_if_needed_follow_returns_immediately_without_release_work(self) -> None self.assertEqual(result["main_sha"], sha) follow.assert_not_called() + def test_idle_if_needed_follow_still_reports_queued_work(self) -> None: + sha = "a" * 40 + client = Mock() + client.base_sha.return_value = sha + client.queued_numbers.return_value = [42] + + with ( + patch("agent_merge_queue.cli.release_follow_needed", return_value=False), + patch("agent_merge_queue.cli.follow_release") as follow, + redirect_stdout(io.StringIO()), + ): + result = command_follow( + client, + timeout_seconds=10, + poll_seconds=1, + json_output=True, + if_needed=True, + ) + + self.assertEqual(result["state"], "idle") + self.assertEqual(result["queue_handoff"]["pull_requests"], [42]) + follow.assert_not_called() + def test_pull_release_details_reads_human_facing_metadata(self) -> None: client = object.__new__(GitHub) client.repository = "example/repo" From 4ad6c30f75e3f25cd7a0169df87c941104585375 Mon Sep 17 00:00:00 2001 From: Matthew Berman <748450+mberman84@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:38:56 -0700 Subject: [PATCH 4/6] Advance immutable coordinator pins --- README.md | 4 ++-- 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, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 88c34c3..611ea61 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Install the reviewed `v0.2.25` source commit directly from GitHub: ```bash python3 -m pip install \ - 'deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@3ba0f2572af57832de77f24f685b1d12b4e3a09d' + 'deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@e9ceb594a9a108b3f5ea4e82f164aed9d42b2501' deploybot init ``` @@ -99,7 +99,7 @@ out or executes pull-request code. Pin the Action to the full reviewed release commit: ```yaml -- uses: Forward-Future/DeployBot@3ba0f2572af57832de77f24f685b1d12b4e3a09d +- uses: Forward-Future/DeployBot@e9ceb594a9a108b3f5ea4e82f164aed9d42b2501 ``` 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 1cd9cf0..ca989c0 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@3ba0f2572af57832de77f24f685b1d12b4e3a09d", + "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@e9ceb594a9a108b3f5ea4e82f164aed9d42b2501", "deploybot-mcp" ] } diff --git a/adapters/cursor/.cursor/mcp.json b/adapters/cursor/.cursor/mcp.json index 1cd9cf0..ca989c0 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@3ba0f2572af57832de77f24f685b1d12b4e3a09d", + "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@e9ceb594a9a108b3f5ea4e82f164aed9d42b2501", "deploybot-mcp" ] } diff --git a/examples/github-workflow.yml b/examples/github-workflow.yml index 53841ad..591eaa3 100644 --- a/examples/github-workflow.yml +++ b/examples/github-workflow.yml @@ -78,7 +78,7 @@ jobs: persist-credentials: false # Reviewed split-coordinator implementation; keep the full commit for # privileged workflows. - - uses: Forward-Future/DeployBot@3ba0f2572af57832de77f24f685b1d12b4e3a09d + - uses: Forward-Future/DeployBot@e9ceb594a9a108b3f5ea4e82f164aed9d42b2501 with: # Keep queue admission independent from release ownership so merged # mode can continue admitting ready work while CI and Deploy run. @@ -99,7 +99,7 @@ jobs: persist-credentials: false # Release-only ownership never promotes or drains pull requests. It # exits immediately when no exact-main release needs work. - - uses: Forward-Future/DeployBot@3ba0f2572af57832de77f24f685b1d12b4e3a09d + - uses: Forward-Future/DeployBot@e9ceb594a9a108b3f5ea4e82f164aed9d42b2501 with: mode: follow timeout: "2400" diff --git a/tests/test_skill.py b/tests/test_skill.py index 55adf95..850494b 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 = "3ba0f2572af57832de77f24f685b1d12b4e3a09d" +RELEASE_COMMIT = "e9ceb594a9a108b3f5ea4e82f164aed9d42b2501" CHECKOUT_COMMIT = "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0" From 3fb42e2e3cf3a6f21cddf43e3d06deaa24a3ac80 Mon Sep 17 00:00:00 2001 From: Matthew Berman <748450+mberman84@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:40:35 -0700 Subject: [PATCH 5/6] Constrain duplicate CI cancellation recovery --- examples/github-workflow.yml | 4 +++- src/agent_merge_queue/pipeline.py | 29 ++++++++++++++++++++++++----- tests/test_pipeline.py | 30 ++++++++++++++++++++++++++++++ tests/test_skill.py | 2 ++ 4 files changed, 59 insertions(+), 6 deletions(-) diff --git a/examples/github-workflow.yml b/examples/github-workflow.yml index 591eaa3..cd44a35 100644 --- a/examples/github-workflow.yml +++ b/examples/github-workflow.yml @@ -87,7 +87,9 @@ jobs: release: needs: react - if: ${{ needs.react.result == 'success' }} + # Release progress is independent from a queue-specific failure, but an + # intentionally cancelled or policy-skipped reaction starts no follower. + if: ${{ always() && (needs.react.result == 'success' || needs.react.result == 'failure') }} runs-on: ubuntu-latest concurrency: group: deploybot-release-${{ github.repository }} diff --git a/src/agent_merge_queue/pipeline.py b/src/agent_merge_queue/pipeline.py index f824f32..42c0e1f 100644 --- a/src/agent_merge_queue/pipeline.py +++ b/src/agent_merge_queue/pipeline.py @@ -73,16 +73,35 @@ def release_state( ) # CI triggered by a merge and CI explicitly dispatched by DeployBot can # race on the same commit. Workflow concurrency may cancel whichever run - # GitHub created last, but that duplicate cancellation cannot invalidate a - # completed substantive run for the identical source tree. Preserve the - # newest non-cancelled result so real later failures and active reruns stay - # authoritative. + # GitHub created last, but that concurrent, different-trigger duplicate + # cannot invalidate a completed success for the identical source tree. A + # later cancelled rerun remains authoritative. A substantive failure or + # active run is always more useful and at least as conservative. if ( substantive_ci is not None and str((ci or {}).get("status") or "") == "completed" and str((ci or {}).get("conclusion") or "") == "cancelled" ): - ci = substantive_ci + substantive_conclusion = str(substantive_ci.get("conclusion") or "") + cancelled_created = parse_time(str((ci or {}).get("created_at") or "")) + substantive_finished = parse_time( + str( + substantive_ci.get("updated_at") + or substantive_ci.get("created_at") + or "" + ) + ) + concurrent_duplicate_success = ( + substantive_conclusion == "success" + and bool((ci or {}).get("event")) + and bool(substantive_ci.get("event")) + and (ci or {}).get("event") != substantive_ci.get("event") + and cancelled_created is not None + and substantive_finished is not None + and cancelled_created <= substantive_finished + ) + if substantive_conclusion != "success" or concurrent_duplicate_success: + ci = substantive_ci # A workflow_run deployment commonly starts for pull-request CI and then # skips itself because the upstream run was not exact main. GitHub reports # the downstream run against the default-branch SHA, so it can otherwise diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 5e09915..da91c68 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -205,6 +205,36 @@ def test_cancelled_duplicate_does_not_hide_intervening_ci_failure(self) -> None: self.assertEqual(value["state"], "ci-failed") self.assertEqual(value["latest_ci"]["id"], 2) + def test_later_cancelled_rerun_remains_authoritative(self) -> None: + sha = "a" * 40 + runs = [ + { + "id": 1, + "name": "CI", + "head_sha": sha, + "status": "completed", + "conclusion": "success", + "event": "workflow_dispatch", + "created_at": "2026-06-20T00:00:00Z", + "updated_at": "2026-06-20T00:01:00Z", + }, + { + "id": 2, + "name": "CI", + "head_sha": sha, + "status": "completed", + "conclusion": "cancelled", + "event": "push", + "created_at": "2026-06-20T00:02:00Z", + "updated_at": "2026-06-20T00:03:00Z", + }, + ] + + value = release_state(main_sha=sha, runs=runs, config=CONFIG.pipeline) + + self.assertEqual(value["state"], "ci-failed") + self.assertEqual(value["latest_ci"]["id"], 2) + def test_later_failed_deploy_is_not_hidden_by_older_success(self) -> None: sha = "a" * 40 runs = [ diff --git a/tests/test_skill.py b/tests/test_skill.py index 850494b..dd4c7f9 100644 --- a/tests/test_skill.py +++ b/tests/test_skill.py @@ -216,6 +216,8 @@ def test_github_workflow_wakes_after_named_ci_finishes(self) -> None: self.assertIn("deploybot-queue-${{ github.repository }}", workflow) self.assertIn("deploybot-release-${{ github.repository }}", workflow) self.assertIn("Release-only ownership never promotes or drains", workflow) + self.assertIn("always()", workflow) + self.assertIn("needs.react.result == 'failure'", workflow) self.assertIn("&& '2400' || '600'", workflow) def test_workflows_pin_current_checkout_runtime(self) -> None: From 59f84f2269f3739ac474a1f98292568bdff98f21 Mon Sep 17 00:00:00 2001 From: Matthew Berman <748450+mberman84@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:40:43 -0700 Subject: [PATCH 6/6] Pin cancellation-safe coordinator --- README.md | 4 ++-- 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, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 611ea61..40251a4 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Install the reviewed `v0.2.25` source commit directly from GitHub: ```bash python3 -m pip install \ - 'deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@e9ceb594a9a108b3f5ea4e82f164aed9d42b2501' + 'deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@3fb42e2e3cf3a6f21cddf43e3d06deaa24a3ac80' deploybot init ``` @@ -99,7 +99,7 @@ out or executes pull-request code. Pin the Action to the full reviewed release commit: ```yaml -- uses: Forward-Future/DeployBot@e9ceb594a9a108b3f5ea4e82f164aed9d42b2501 +- uses: Forward-Future/DeployBot@3fb42e2e3cf3a6f21cddf43e3d06deaa24a3ac80 ``` 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 ca989c0..d173803 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@e9ceb594a9a108b3f5ea4e82f164aed9d42b2501", + "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@3fb42e2e3cf3a6f21cddf43e3d06deaa24a3ac80", "deploybot-mcp" ] } diff --git a/adapters/cursor/.cursor/mcp.json b/adapters/cursor/.cursor/mcp.json index ca989c0..d173803 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@e9ceb594a9a108b3f5ea4e82f164aed9d42b2501", + "deploybot-merge-queue[mcp] @ git+https://github.com/Forward-Future/DeployBot.git@3fb42e2e3cf3a6f21cddf43e3d06deaa24a3ac80", "deploybot-mcp" ] } diff --git a/examples/github-workflow.yml b/examples/github-workflow.yml index cd44a35..a59e276 100644 --- a/examples/github-workflow.yml +++ b/examples/github-workflow.yml @@ -78,7 +78,7 @@ jobs: persist-credentials: false # Reviewed split-coordinator implementation; keep the full commit for # privileged workflows. - - uses: Forward-Future/DeployBot@e9ceb594a9a108b3f5ea4e82f164aed9d42b2501 + - uses: Forward-Future/DeployBot@3fb42e2e3cf3a6f21cddf43e3d06deaa24a3ac80 with: # Keep queue admission independent from release ownership so merged # mode can continue admitting ready work while CI and Deploy run. @@ -101,7 +101,7 @@ jobs: persist-credentials: false # Release-only ownership never promotes or drains pull requests. It # exits immediately when no exact-main release needs work. - - uses: Forward-Future/DeployBot@e9ceb594a9a108b3f5ea4e82f164aed9d42b2501 + - uses: Forward-Future/DeployBot@3fb42e2e3cf3a6f21cddf43e3d06deaa24a3ac80 with: mode: follow timeout: "2400" diff --git a/tests/test_skill.py b/tests/test_skill.py index dd4c7f9..53acb29 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 = "e9ceb594a9a108b3f5ea4e82f164aed9d42b2501" +RELEASE_COMMIT = "3fb42e2e3cf3a6f21cddf43e3d06deaa24a3ac80" CHECKOUT_COMMIT = "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0"